diff --git a/.gitignore b/.gitignore index 5e75d03cd..d2c5a8a5f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ workflow create_issue +# Diagnostic harness output (scripts/diagnose-cef-runtime.mjs) +diagnosis-*.json + # Logs logs *.log diff --git a/app/src-tauri/src/webview_accounts/mod.rs b/app/src-tauri/src/webview_accounts/mod.rs index 872406048..4c7cbb307 100644 --- a/app/src-tauri/src/webview_accounts/mod.rs +++ b/app/src-tauri/src/webview_accounts/mod.rs @@ -23,7 +23,7 @@ use std::path::PathBuf; use std::sync::Mutex; #[cfg(target_os = "linux")] use std::sync::{mpsc::sync_channel, OnceLock}; -use std::time::Duration; +use std::time::{Duration, Instant}; use chrono::{TimeZone, Utc}; use serde::{Deserialize, Serialize}; @@ -162,6 +162,17 @@ fn url_is_internal(provider: &str, url: &Url) -> bool { let Some(host) = url.host_str() else { return true; }; + // Google services route the post-2FA `SetSID` cookie-setting hop + // through `accounts.youtube.com` and ccTLD `accounts.google.` + // hosts that aren't covered by the suffix-based allowlist. Without + // this, the auth chain breaks mid-flight and leaks to the system + // browser (#1053 sign-in leak surfaced in dev:app log line: + // "external navigation https://accounts.youtube.com/accounts/SetSID?... + // → system browser"). Whitelist the full Google SSO host family for + // any provider that uses Google identity. + if matches!(provider, "gmail" | "google-meet") && is_google_sso_host(host) { + return true; + } let allowed = provider_allowed_hosts(provider); if allowed.is_empty() { return true; @@ -276,13 +287,55 @@ fn popup_should_navigate_parent(provider: &str, url: &Url) -> Option { None } +/// `true` if `host` is a Google SSO / account-handoff host that may +/// participate in the OAuth flow for any Google service (Meet, Gmail, +/// Drive, etc.). Google rotates the post-2FA `SetSID` cookie-setting hop +/// across `accounts.google.` and `accounts.youtube.com` (sic — the +/// YouTube subdomain is part of the Google identity infra), so a literal +/// `accounts.google.com` match misses real auth popups and leaks them to +/// the system browser. +/// +/// Match family: +/// - `accounts.google.com` +/// - `accounts.google.` — e.g. `accounts.google.co.in`, `accounts.google.co.uk`, +/// `accounts.google.de` +/// - `accounts.googleusercontent.com` +/// - `accounts.youtube.com` (post-2FA `SetSID` hop) +/// - `myaccount.google.com` +fn is_google_sso_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + if host == "accounts.google.com" + || host == "accounts.googleusercontent.com" + || host == "accounts.youtube.com" + || host == "myaccount.google.com" + { + return true; + } + // ccTLD variants: `accounts.google.`. We must reject phishing + // shapes like `accounts.google.com.evil` and `accounts.google.co.attacker` + // — the dots-only check we used previously accepted both because + // `com.evil` and `co.attacker` each have one dot. Anchor the suffix + // against a real ccTLD shape: either a single 2-letter cc tld + // (`accounts.google.de`, `accounts.google.fr`) OR a 2-label form + // `.` where sld ∈ {co, com, net, org} (`accounts.google.co.in`, + // `accounts.google.com.au`). + if let Some(rest) = host.strip_prefix("accounts.google.") { + let labels: Vec<&str> = rest.split('.').collect(); + let is_cc = |s: &str| s.len() == 2 && s.chars().all(|c| c.is_ascii_alphabetic()); + return match labels.as_slice() { + [tld] => is_cc(tld), + [sld, tld] => matches!(*sld, "co" | "com" | "net" | "org") && is_cc(tld), + _ => false, + }; + } + false +} + fn is_google_auth_popup(url: &Url) -> bool { let Some(host) = url.host_str() else { return false; }; - let is_google_auth_host = - host == "accounts.google.com" || host == "accounts.googleusercontent.com"; - if !is_google_auth_host { + if !is_google_sso_host(host) { return false; } @@ -291,6 +344,7 @@ fn is_google_auth_popup(url: &Url) -> bool { || path.contains("servicelogin") || path.contains("accountchooser") || path.contains("chooseaccount") + || path.contains("setsid") { return true; } @@ -302,10 +356,33 @@ fn is_google_auth_popup(url: &Url) -> bool { && (v.contains("signin") || v.contains("servicelogin") || v.contains("accountchooser") - || v.contains("chooseaccount")) + || v.contains("chooseaccount") + || v.contains("meet.google.com") + || v.contains("mail.google.com")) }) } +/// `true` if a gmeet navigation lands on Google's Workspace marketing page +/// for Meet — the host bounce that fires when an unauthenticated webview hits +/// `meet.google.com`. +/// +/// The `on_navigation` rewrite is scoped to this exact path family so we +/// don't hijack legitimate `workspace.google.com` pages a user might reach +/// from inside Meet (admin console links, Workspace Status, support pages, +/// etc.). Matches `workspace.google.com` (and any subdomain) AND a path +/// starting with `/products/meet` — empirically the only path Google's +/// edge bounces unauthenticated Meet GETs to (`/products/meet/` or +/// `/products/meet/`). +fn is_gmeet_marketing_redirect(host: &str, path: &str) -> bool { + let host = host.to_ascii_lowercase(); + let host_matches = host == "workspace.google.com" || host.ends_with(".workspace.google.com"); + if !host_matches { + return false; + } + let p = path.to_ascii_lowercase(); + p == "/products/meet" || p == "/products/meet/" || p.starts_with("/products/meet/") +} + fn redact_navigation_url(url: &Url) -> String { let mut safe = url.clone(); safe.set_query(None); @@ -538,9 +615,105 @@ pub struct WebviewAccountsState { requested_bounds: Mutex>, /// Runtime notification-bypass controls used by the settings UI. notification_bypass: Mutex, + /// Per-label rewrite counter for the gmeet `workspace.google.com` + /// marketing intercept. Google's edge SSR-redirects unauthenticated + /// `meet.google.com` GETs back to `workspace.google.com/products/meet/`, + /// so an unguarded rewrite (see `:1534-1559`) ping-pongs forever and + /// the page never lands. We track `(last_attempt, attempts_in_window)` + /// per webview label and bail to the Google sign-in flow after a + /// threshold so the user breaks out of the loop. + gmeet_marketing_rewrites: Mutex>, + /// Per-label "awaiting post-auth handoff" flag. Set when the gmeet + /// rewrite-loop bail navigates to `accounts.google.com/ServiceLogin`, + /// consumed (single-shot) by the `myaccount.google.com` intercept so + /// only the immediate post-auth bounce gets rewritten back to Meet — + /// legitimate user-initiated `myaccount.google.com` navigations (e.g. + /// "Manage your Google Account" from the avatar menu) are passed + /// through unchanged. + gmeet_awaiting_handoff: Mutex>, +} + +/// Threshold and window for the gmeet workspace-marketing rewrite loop +/// breaker. After `GMEET_REWRITE_MAX_ATTEMPTS` rewrites within +/// `GMEET_REWRITE_WINDOW`, we bail to the Google sign-in URL instead of +/// rewriting again. +pub(crate) const GMEET_REWRITE_MAX_ATTEMPTS: u32 = 3; +pub(crate) const GMEET_REWRITE_WINDOW: Duration = Duration::from_secs(5); + +/// Result of consulting the gmeet marketing-redirect counter. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum GmeetRewriteAction { + /// Allow the rewrite — fewer than `GMEET_REWRITE_MAX_ATTEMPTS` attempts in + /// the current window. + Rewrite, + /// Loop detected — caller should navigate to the Google sign-in URL + /// instead of rewriting back to `meet.google.com`. + Bail, } impl WebviewAccountsState { + /// Drop the gmeet marketing-rewrite counter for `label`. Called after + /// the post-auth handoff so a future workspace-marketing bounce (which + /// shouldn't occur post-auth, but could on session expiry) gets a fresh + /// counter window instead of inheriting half-saturated state from the + /// pre-auth loop. + pub(crate) fn clear_gmeet_marketing_rewrite(&self, label: &str) { + if let Ok(mut g) = self.gmeet_marketing_rewrites.lock() { + g.remove(label); + } + } + + /// Mark `label` as awaiting the post-auth `myaccount.google.com` → + /// `meet.google.com` handoff. Set by the rewrite-loop bail right + /// before navigating to `accounts.google.com/ServiceLogin?continue=`, + /// so the next `myaccount.google.com` commit on this label is treated + /// as the auth chain's terminal hop (the `?utm_source=sign_in_no_continue` + /// dump-page) and gets force-redirected to Meet. + pub(crate) fn mark_awaiting_gmeet_handoff(&self, label: &str) { + if let Ok(mut g) = self.gmeet_awaiting_handoff.lock() { + g.insert(label.to_string()); + } + } + + /// Single-shot consume of the post-auth handoff flag for `label`. + /// Returns `true` exactly once after `mark_awaiting_gmeet_handoff`, + /// then resets so subsequent `myaccount.google.com` navigations + /// (e.g. user-initiated profile/settings visits) pass through as + /// normal and aren't hijacked back to Meet. + pub(crate) fn take_awaiting_gmeet_handoff(&self, label: &str) -> bool { + match self.gmeet_awaiting_handoff.lock() { + Ok(mut g) => g.remove(label), + Err(_) => false, + } + } + + /// Increment the per-label gmeet marketing-rewrite counter for `now` and + /// decide whether to rewrite or bail. Resets the counter when the last + /// attempt was outside `GMEET_REWRITE_WINDOW` so a future genuine + /// `workspace.google.com` navigation (e.g. user clicks a link inside Meet + /// after sign-in) gets intercepted normally. + pub(crate) fn track_gmeet_marketing_rewrite( + &self, + label: &str, + now: Instant, + ) -> GmeetRewriteAction { + let mut map = match self.gmeet_marketing_rewrites.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + let entry = map.entry(label.to_string()).or_insert((now, 0)); + if now.duration_since(entry.0) > GMEET_REWRITE_WINDOW { + *entry = (now, 0); + } + entry.1 += 1; + entry.0 = now; + if entry.1 > GMEET_REWRITE_MAX_ATTEMPTS { + GmeetRewriteAction::Bail + } else { + GmeetRewriteAction::Rewrite + } + } + /// Drain every per-account resource owned by this state and abort the /// associated background tasks. Returns the `(account_id, label)` /// pairs of webviews that still need closing — the caller does the @@ -596,6 +769,18 @@ impl WebviewAccountsState { if let Ok(mut g) = self.requested_bounds.lock() { g.clear(); } + // Per-label gmeet rewrite counter must clear too — `label_for()` + // reuses the same label on reopen, so a stale saturated entry + // would jump a fresh open straight to the bail URL. + if let Ok(mut g) = self.gmeet_marketing_rewrites.lock() { + g.clear(); + } + // Drop the post-auth handoff flag too — a stale flag would + // hijack the first user-initiated `myaccount.google.com` visit + // after a relaunch back to Meet. + if let Ok(mut g) = self.gmeet_awaiting_handoff.lock() { + g.clear(); + } self.inner .lock() .ok() @@ -1558,22 +1743,122 @@ pub async fn webview_account_open( // would land on the Workspace marketing page instead of Meet. // Catch this here and replace the parent URL with the canonical // Meet entry point so the embedded view stays on the app. + // + // BUT: unauthenticated users get bounced right back to + // `workspace.google.com/products/meet/` by Google's edge, so an + // unguarded rewrite ping-pongs forever (`navigate` → Google + // redirect → `on_navigation` → `navigate` → …). We track per-label + // attempts in `WebviewAccountsState::gmeet_marketing_rewrites` and + // bail to a Google sign-in URL after a small threshold so the user + // can break out of the loop. See #1213 (downstream watchdog + // symptom) and `track_gmeet_marketing_rewrite` for the policy. if nav_provider == "google-meet" { if let Some(host) = url.host_str() { - if host == "workspace.google.com" || host.ends_with(".workspace.google.com") { - log::info!( - "[webview-accounts] gmeet workspace marketing redirect intercepted ({}); rewriting parent to https://meet.google.com/", - url - ); + // Post-auth handoff: when the bail's `ServiceLogin?continue=` + // chain completes, Google sometimes drops the continue param + // (URL ends in `?utm_source=sign_in_no_continue`) and dumps + // the user on `myaccount.google.com` instead of Meet. The + // session cookie is now valid, so a direct navigation to + // `meet.google.com` will paint Meet without bouncing back + // through the workspace marketing redirect (which was the + // unauthenticated branch). Force the hop here so the user + // doesn't have to click through the apps grid manually. + // + // Gated on the per-label `gmeet_awaiting_handoff` flag — + // set by the Bail branch right before navigating to + // `ServiceLogin?continue=` — so legitimate user-initiated + // visits to `myaccount.google.com` (e.g. "Manage your + // Google Account" from the avatar menu) pass through and + // remain reachable in-app. + if host == "myaccount.google.com" { + let consumed = nav_app + .try_state::() + .map(|s| s.take_awaiting_gmeet_handoff(&nav_label)) + .unwrap_or(false); + if !consumed { + log::debug!( + "[webview-accounts] gmeet myaccount.google.com nav (label={}) not in handoff window; passing through", + nav_label + ); + } else { + log::info!( + "[webview-accounts] gmeet post-auth handoff detected on myaccount.google.com; navigating parent to https://meet.google.com/" + ); + let app = nav_app.clone(); + let label = nav_label.clone(); + // Reset the marketing-rewrite counter so the next + // workspace bounce (if any) gets a fresh window — + // the user is now authenticated and shouldn't + // loop again. + if let Some(s) = nav_app.try_state::() { + s.clear_gmeet_marketing_rewrite(&nav_label); + } + tauri::async_runtime::spawn(async move { + if let Some(wv) = app.get_webview(&label) { + if let Ok(target) = Url::parse("https://meet.google.com/") { + if let Err(e) = wv.navigate(target) { + log::warn!( + "[webview-accounts] gmeet post-auth navigate failed label={} err={}", + label, + e + ); + } + } + } + }); + return false; + } + } + if is_gmeet_marketing_redirect(host, url.path()) { + let action = nav_app + .try_state::() + .map(|s| s.track_gmeet_marketing_rewrite(&nav_label, Instant::now())) + .unwrap_or(GmeetRewriteAction::Rewrite); let app = nav_app.clone(); let label = nav_label.clone(); + let target_url = match action { + GmeetRewriteAction::Rewrite => { + log::info!( + "[webview-accounts] gmeet workspace marketing redirect intercepted ({}); rewriting parent to https://meet.google.com/", + url + ); + "https://meet.google.com/" + } + GmeetRewriteAction::Bail => { + log::warn!( + "[webview-accounts] gmeet workspace rewrite loop detected on label={} (>{} attempts in {}s); falling through to Google sign-in", + nav_label, + GMEET_REWRITE_MAX_ATTEMPTS, + GMEET_REWRITE_WINDOW.as_secs() + ); + // Arm the post-auth handoff flag so the next + // `myaccount.google.com` commit on this label + // (the `?utm_source=sign_in_no_continue` dump + // page Google sometimes drops users on after + // the ServiceLogin chain) gets force-redirected + // back to Meet. Without this gate, ANY + // `myaccount.google.com` visit was hijacked, + // breaking legitimate "Manage your Google + // Account" flows. + if let Some(s) = nav_app.try_state::() { + s.mark_awaiting_gmeet_handoff(&nav_label); + } + // `service=meet` is rejected by Google (`400 + // malformed`); the continue param must be + // URL-encoded since `&` would split it. Drop + // `service=` and encode `continue=` so the + // sign-in landing actually loads. + "https://accounts.google.com/ServiceLogin?continue=https%3A%2F%2Fmeet.google.com%2F" + } + }; tauri::async_runtime::spawn(async move { if let Some(wv) = app.get_webview(&label) { - if let Ok(target) = Url::parse("https://meet.google.com/") { + if let Ok(target) = Url::parse(target_url) { if let Err(e) = wv.navigate(target) { log::warn!( - "[webview-accounts] gmeet workspace rewrite navigate failed label={} err={}", + "[webview-accounts] gmeet workspace rewrite navigate failed label={} target={} err={}", label, + target_url, e ); } @@ -2093,6 +2378,10 @@ pub async fn webview_account_close( .lock() .unwrap() .remove(&args.account_id); + // Drop any gmeet workspace-rewrite counter for this label — labels are + // reused on reopen, so a stale entry from a closed-mid-loop session + // would saturate the next fresh open's window. + state.clear_gmeet_marketing_rewrite(&label); log::info!("[webview-accounts] closed label={}", label); Ok(()) } @@ -2156,6 +2445,13 @@ pub async fn webview_account_purge( .lock() .unwrap() .remove(&args.account_id); + if let Some(label) = label_opt.as_ref() { + state.clear_gmeet_marketing_rewrite(label); + // Drop any pending handoff flag for this label so a stale entry + // can't hijack the next genuine `myaccount.google.com` visit on + // a webview that re-uses the same label. + state.take_awaiting_gmeet_handoff(label); + } let data_dir = data_directory_for(&app, &args.account_id)?; purge_data_dir_with_retry(&data_dir) @@ -2631,6 +2927,13 @@ mod tests { height: 600.0, }, ); + // Saturate the gmeet rewrite counter so we can assert it gets + // cleared by drain (otherwise the next reopen would inherit a + // stale entry — `label_for()` reuses the same label). + for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { + let _ = state.track_gmeet_marketing_rewrite("acct_1", Instant::now()); + } + assert!(!state.gmeet_marketing_rewrites.lock().unwrap().is_empty()); let labels = state.drain_for_shutdown(); tokio::task::yield_now().await; @@ -2651,6 +2954,10 @@ mod tests { assert!(state.inner.lock().unwrap().is_empty()); assert!(state.loaded_accounts.lock().unwrap().is_empty()); assert!(state.requested_bounds.lock().unwrap().is_empty()); + assert!( + state.gmeet_marketing_rewrites.lock().unwrap().is_empty(), + "gmeet rewrite counter must clear on drain so reopens don't inherit stale entries" + ); // Second call must be a safe no-op: nothing left to drain. let labels2 = state.drain_for_shutdown(); @@ -3107,4 +3414,268 @@ mod tests { assert!(!dir.exists(), "data dir should be removed"); } + + // ── track_gmeet_marketing_rewrite ────────────────────────────── + + #[test] + fn gmeet_rewrite_allowed_under_threshold() { + let state = WebviewAccountsState::default(); + let label = "acct_test"; + let now = Instant::now(); + for i in 1..=GMEET_REWRITE_MAX_ATTEMPTS { + assert_eq!( + state.track_gmeet_marketing_rewrite(label, now), + GmeetRewriteAction::Rewrite, + "attempt {} should still rewrite", + i + ); + } + } + + #[test] + fn gmeet_rewrite_bails_after_threshold() { + let state = WebviewAccountsState::default(); + let label = "acct_test"; + let now = Instant::now(); + for _ in 0..GMEET_REWRITE_MAX_ATTEMPTS { + let _ = state.track_gmeet_marketing_rewrite(label, now); + } + // Next call exceeds the threshold within the window — must bail. + assert_eq!( + state.track_gmeet_marketing_rewrite(label, now), + GmeetRewriteAction::Bail + ); + } + + #[test] + fn gmeet_rewrite_resets_after_window() { + let state = WebviewAccountsState::default(); + let label = "acct_test"; + let start = Instant::now(); + // Saturate the counter at start. + for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { + let _ = state.track_gmeet_marketing_rewrite(label, start); + } + // After the window expires, a fresh attempt must rewrite again. + let later = start + GMEET_REWRITE_WINDOW + Duration::from_secs(1); + assert_eq!( + state.track_gmeet_marketing_rewrite(label, later), + GmeetRewriteAction::Rewrite + ); + } + + // ── is_google_sso_host ──────────────────────────────────────── + + #[test] + fn google_sso_host_matches_canonical_accounts() { + assert!(is_google_sso_host("accounts.google.com")); + assert!(is_google_sso_host("accounts.googleusercontent.com")); + assert!(is_google_sso_host("accounts.youtube.com")); + assert!(is_google_sso_host("myaccount.google.com")); + } + + #[test] + fn google_sso_host_matches_cctld_variants() { + assert!(is_google_sso_host("accounts.google.co.in")); + assert!(is_google_sso_host("accounts.google.co.uk")); + assert!(is_google_sso_host("accounts.google.de")); + assert!(is_google_sso_host("accounts.google.fr")); + assert!(is_google_sso_host("accounts.google.com.au")); + } + + #[test] + fn google_sso_host_rejects_phishing_alikes() { + // Spoofed hosts that hijack the full domain by prefixing `accounts.google.`. + assert!(!is_google_sso_host("accounts.google.com.evil.tld")); + assert!(!is_google_sso_host("accounts.google.")); + assert!(!is_google_sso_host("accounts.google.com.evil.example.com")); + // Two-label suffix where the second label is NOT a real cctld + // (the dots-only predicate accepted these — CR caught it). + assert!(!is_google_sso_host("accounts.google.com.evil")); + assert!(!is_google_sso_host("accounts.google.co.attacker")); + assert!(!is_google_sso_host("accounts.google.com.attackerlong")); + // Single label that's not a real cctld (3+ chars). + assert!(!is_google_sso_host("accounts.google.evil")); + assert!(!is_google_sso_host("accounts.google.attackerlong")); + // Unknown sld in the 2-label shape — only co/com/net/org allowed. + assert!(!is_google_sso_host("accounts.google.xyz.uk")); + // Unrelated google sub-services that aren't sso surfaces. + assert!(!is_google_sso_host("mail.google.com")); + assert!(!is_google_sso_host("meet.google.com")); + assert!(!is_google_sso_host("workspace.google.com")); + assert!(!is_google_sso_host("evil.com")); + } + + #[test] + fn google_sso_host_case_insensitive() { + assert!(is_google_sso_host("ACCOUNTS.GOOGLE.COM")); + assert!(is_google_sso_host("Accounts.Google.Co.Uk")); + } + + // ── url_is_internal: gmeet SSO coverage ─────────────────────── + + #[test] + fn url_is_internal_allows_youtube_setsid_for_gmeet() { + assert!(url_is_internal( + "google-meet", + &url("https://accounts.youtube.com/accounts/SetSID?ssdc=1&continue=https://meet.google.com/"), + )); + } + + #[test] + fn url_is_internal_allows_cctld_accounts_google_for_gmail() { + assert!(url_is_internal( + "gmail", + &url("https://accounts.google.co.in/signin/v2/identifier"), + )); + } + + #[test] + fn url_is_internal_blocks_unrelated_youtube_for_gmeet() { + // Plain youtube.com (e.g. video play) MUST stay external for + // gmeet — the SSO bypass only covers `accounts.youtube.com`. + assert!(!url_is_internal( + "google-meet", + &url("https://www.youtube.com/watch?v=abc"), + )); + } + + #[test] + fn gmeet_rewrite_per_label_independent() { + let state = WebviewAccountsState::default(); + let now = Instant::now(); + // Saturate label A — bails next time. + for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { + let _ = state.track_gmeet_marketing_rewrite("acct_a", now); + } + // Label B must still be allowed independently. + assert_eq!( + state.track_gmeet_marketing_rewrite("acct_b", now), + GmeetRewriteAction::Rewrite + ); + } + + // ── is_gmeet_marketing_redirect ──────────────────────────────── + + #[test] + fn gmeet_marketing_match_canonical_paths() { + assert!(is_gmeet_marketing_redirect( + "workspace.google.com", + "/products/meet/" + )); + assert!(is_gmeet_marketing_redirect( + "workspace.google.com", + "/products/meet" + )); + assert!(is_gmeet_marketing_redirect( + "workspace.google.com", + "/products/meet/learn-more" + )); + assert!(is_gmeet_marketing_redirect( + "WORKSPACE.GOOGLE.COM", + "/PRODUCTS/MEET/" + )); + } + + #[test] + fn gmeet_marketing_match_subdomain_workspace() { + assert!(is_gmeet_marketing_redirect( + "support.workspace.google.com", + "/products/meet/faq" + )); + } + + #[test] + fn gmeet_marketing_rejects_other_workspace_paths() { + // Legitimate Workspace pages a user might reach from Meet must NOT + // be hijacked — admin console, Workspace Status, support, etc. + assert!(!is_gmeet_marketing_redirect("workspace.google.com", "/")); + assert!(!is_gmeet_marketing_redirect( + "workspace.google.com", + "/products/calendar/" + )); + assert!(!is_gmeet_marketing_redirect( + "admin.workspace.google.com", + "/ac/users" + )); + assert!(!is_gmeet_marketing_redirect( + "workspace.google.com", + "/status" + )); + assert!(!is_gmeet_marketing_redirect( + "workspace.google.com", + "/products/meeter" + )); + } + + #[test] + fn gmeet_marketing_rejects_non_workspace_hosts() { + assert!(!is_gmeet_marketing_redirect( + "meet.google.com", + "/products/meet/" + )); + assert!(!is_gmeet_marketing_redirect("evil.com", "/products/meet/")); + // Phishing alike: workspace-google.com is NOT workspace.google.com + assert!(!is_gmeet_marketing_redirect( + "workspace-google.com", + "/products/meet/" + )); + } + + #[test] + fn gmeet_clear_marketing_rewrite_drops_counter() { + let state = WebviewAccountsState::default(); + let now = Instant::now(); + for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { + let _ = state.track_gmeet_marketing_rewrite("acct_test", now); + } + // Counter saturated — next call would bail. + assert_eq!( + state.track_gmeet_marketing_rewrite("acct_test", now), + GmeetRewriteAction::Bail + ); + // Clear it — next call within the window starts fresh. + state.clear_gmeet_marketing_rewrite("acct_test"); + assert_eq!( + state.track_gmeet_marketing_rewrite("acct_test", now), + GmeetRewriteAction::Rewrite + ); + } + + #[test] + fn gmeet_handoff_flag_default_is_unset() { + let state = WebviewAccountsState::default(); + assert!(!state.take_awaiting_gmeet_handoff("acct_test")); + } + + #[test] + fn gmeet_handoff_flag_marks_then_consumes_single_shot() { + let state = WebviewAccountsState::default(); + state.mark_awaiting_gmeet_handoff("acct_test"); + // First take returns true. + assert!(state.take_awaiting_gmeet_handoff("acct_test")); + // Second take returns false — single-shot semantics so a later + // user-initiated `myaccount.google.com` visit isn't hijacked. + assert!(!state.take_awaiting_gmeet_handoff("acct_test")); + } + + #[test] + fn gmeet_handoff_flag_is_per_label() { + let state = WebviewAccountsState::default(); + state.mark_awaiting_gmeet_handoff("acct_a"); + // `acct_b` was never marked — must not consume a flag set on `acct_a`. + assert!(!state.take_awaiting_gmeet_handoff("acct_b")); + // `acct_a`'s flag is still pending. + assert!(state.take_awaiting_gmeet_handoff("acct_a")); + } + + #[test] + fn gmeet_handoff_flag_cleared_by_drain_for_shutdown() { + let state = WebviewAccountsState::default(); + state.mark_awaiting_gmeet_handoff("acct_test"); + let _ = state.drain_for_shutdown(); + // Stale flag would hijack the first user-initiated + // `myaccount.google.com` visit after relaunch. + assert!(!state.take_awaiting_gmeet_handoff("acct_test")); + } } diff --git a/docs/qa/GMEET-PARITY.md b/docs/qa/GMEET-PARITY.md index 58caffba1..5c68e86f2 100644 --- a/docs/qa/GMEET-PARITY.md +++ b/docs/qa/GMEET-PARITY.md @@ -34,7 +34,7 @@ | 12 | **Session persistence** — close + reopen webview tab without sign-out; account stays active | ✅ pass | CEF profile persistence behaves identically to gmail/slack. | — | | 13 | **Captions ingestion → memory** — `meet_captions` rows + `meet_call_ended` lifecycle event flush a transcript document via `openhuman.memory_doc_ingest` | ⏭️ deferred to #1052 | Current handler at `webview_accounts/mod.rs:2219-2247` is log-only. Not a regression — recipe.js has been log-only since inception. Filed as **feature** at #1052 with module sketch (`google_meet/` mirroring `gmail/` shape, persistent `MeetingTranscriptStore`, opt-in toggle for the privacy-sensitive transcript). | Out of scope for this PR. | | 14 | **Hangup return UX** — leaving the call returns to a recognizable state (rejoin / 5-star review prompt) | ✅ pass | Behavior is identical to stock Chrome — rejoin button + 5-star review with 60s auto-dismiss. | — | -| 15 | **Background effects** — virtual background / blur applies without breaking the camera | ❌ deferred to #1053 | Selecting a virtual background turns the camera off and Gmeet shows a "something went wrong" toast. Hypothesis: Chromium runtime gates absent in the vendored CEF build (insertable streams `MediaStreamTrackProcessor` / `MediaStreamTrackGenerator`, MediaPipe segmentation requiring SharedArrayBuffer + COOP/COEP cross-origin isolation, HW accel). | Filed as **bug** at #1053 with investigation sketch. Out of scope for this PR — needs CEF runtime-flag tuning, separate from the `webview_accounts` parity work. | +| 15 | **Background effects** — virtual background / blur applies without breaking the camera | ⚠️ partial — static effects work, dynamic (video) effects fail | **Phase A diagnostic 2026-05-05** ruled out the original hypothesis. Probe results inside an active Meet call: `crossOriginIsolated:false` (irrelevant — SAB is exposed via the existing `--enable-features=SharedArrayBuffer` flag), `hasSAB:true`, `hasInsertableStreams:true` (MediaStreamTrackProcessor/Generator both present), `hasWebGL2:true`, `hasWebGPU:true`, `hasAtomics:true`. **Static backgrounds (blur, classroom, library, etc.) WORK** — verified live. **Dynamic (video) backgrounds fail** with `MEDIA_ERR_SRC_NOT_SUPPORTED: PipelineStatus::DEMUXER_ERROR_NO_SUPPORTED_STREAMS: FFmpegDemuxer: no supported streams` when fetching `https://www.gstatic.com/video_effects/assets/.mp4`. Real root cause: vendored CEF ships the standard distribution (no proprietary codecs), so H.264-in-MP4 dynamic-bg assets can't be demuxed. Not a Chromium runtime gap. Memory `feedback_cef_runtime_gaps.md` gap #3 reclassified accordingly. | Reclassified — needs proprietary codec build of vendored `tauri-cef`. Will file follow-up against `tinyhumansai/tauri-cef` to switch CEF binary distribution to a Chrome-branded build (or document as won't-fix per license). Out of scope for openhuman code. | ## Smoke run procedure @@ -72,8 +72,8 @@ The legacy `recipe.js` polls the call DOM for caption rows. A future epic should ## Sign-off - Tester: oxoxDev -- Result: 11 ✅ / 0 ⚠️ / 2 ❌ (one blocked on #1046, one deferred to #1053) / 1 ⏭️ deferred to #1052 / 1 ❌ blocked on #1046 -- Date: 2026-04-30 +- Result: 12 ✅ / 1 ⚠️ partial (#1053 row 15 — CEF codec gap, not a runtime gap) / 1 ❌ blocked on #1046 (row 1) / 1 ⏭️ deferred to #1052 (row 13) +- Date: 2026-04-30 (row 15 reclassified 2026-05-05 after Phase A diagnostic) - Action items: - Land #1046 (gmail PR) → rebase this branch → row #1 Auth flips ✅ - Track #1052 (caption ingest feature) and #1053 (background effects CEF gap) as follow-up issues diff --git a/package.json b/package.json index 148af9e1e..96ab5b8bb 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "typecheck": "pnpm --filter openhuman-app compile" }, "devDependencies": { - "husky": "^9.1.7" + "husky": "^9.1.7", + "ws": "^8.20.0" }, "dependencies": { "@tauri-apps/api": "2.10.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b54e372bc..c14b15056 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + ws: + specifier: ^8.20.0 + version: 8.20.0 app: dependencies: diff --git a/scripts/diagnose-cef-runtime.mjs b/scripts/diagnose-cef-runtime.mjs new file mode 100644 index 000000000..f54e435a0 --- /dev/null +++ b/scripts/diagnose-cef-runtime.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +// CEF runtime capability diagnostic — connects to the embedded webview's +// CDP debug port (`localhost:19222`, set by `lib.rs:1201`) and runs one +// of three probes against an active provider target. Surfaced during +// #1053 Phase A diagnostic but reusable for any CEF runtime audit +// (Web Push gap, BrowserChannel long-poll, codec demux, etc — see +// `feedback_cef_runtime_gaps.md`). +// +// Run while `pnpm dev:app` is up and a provider webview (gmeet, gmail, +// slack, …) has loaded its real URL — the harness picks the first target +// whose URL or title contains "meet" by default; tweak `pickGmeetTarget` +// to scope to a different provider. +// +// Usage: +// node scripts/diagnose-cef-runtime.mjs probe # capability-gate snapshot +// # (crossOriginIsolated, SAB, +// # insertable streams, +// # WebGL2 / WebGPU, Atomics) +// node scripts/diagnose-cef-runtime.mjs headers # tail Network.responseReceived +// # for COOP / COEP / CORP + +// # any provider response (Ctrl-C dumps) +// node scripts/diagnose-cef-runtime.mjs watch # tail Console.messageAdded + +// # Runtime.exceptionThrown +// # (Ctrl-C dumps) +// +// Output goes to ./diagnosis--.json. The transient JSONs +// are intentionally NOT committed (`.gitignore` excludes them). + +import { writeFileSync } from 'node:fs'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { argv } from 'node:process'; + +const CDP_HOST = 'localhost'; +const CDP_PORT = 19222; + +const ts = () => new Date().toISOString().replace(/[:.]/g, '-'); +const out = (mode, data) => { + const path = `diagnosis-${mode}-${ts()}.json`; + writeFileSync(path, JSON.stringify(data, null, 2)); + console.log(`[diagnose] wrote ${path}`); +}; + +async function listTargets() { + const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/list`); + if (!res.ok) throw new Error(`json/list ${res.status}`); + return await res.json(); +} + +async function pickGmeetTarget() { + const targets = await listTargets(); + const gmeet = targets.filter( + (t) => + t.type === 'page' && + (t.url?.includes('meet.google.com') || t.title?.toLowerCase().includes('meet')), + ); + if (!gmeet.length) { + console.error('[diagnose] No meet.google.com target found. All page targets:'); + for (const t of targets.filter((t) => t.type === 'page')) { + console.error(` ${t.title} :: ${t.url}`); + } + process.exit(2); + } + return gmeet[0]; +} + +async function attach(target) { + const ws = await import('ws').catch(() => null); + if (!ws) { + console.error('[diagnose] missing ws — install via: pnpm add -D ws (or copy to /tmp + npm i ws)'); + process.exit(2); + } + const WebSocket = ws.default ?? ws.WebSocket; + const sock = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; + const pending = new Map(); + const events = []; + sock.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.id != null && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id); + pending.delete(msg.id); + msg.error ? reject(msg.error) : resolve(msg.result); + } else if (msg.method) { + events.push({ at: ts(), ...msg }); + } + }); + await new Promise((resolve, reject) => { + sock.once('open', resolve); + sock.once('error', reject); + }); + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const i = ++id; + pending.set(i, { resolve, reject }); + sock.send(JSON.stringify({ id: i, method, params })); + }); + return { send, events, close: () => sock.close() }; +} + +async function modeProbe() { + const target = await pickGmeetTarget(); + console.log(`[diagnose] probe :: ${target.url}`); + const cdp = await attach(target); + const expr = `JSON.stringify({ + crossOriginIsolated: window.crossOriginIsolated, + hasSAB: typeof SharedArrayBuffer === 'function', + hasInsertableStreams: 'MediaStreamTrackProcessor' in window && 'MediaStreamTrackGenerator' in window, + hasWebGL2: !!document.createElement('canvas').getContext('webgl2'), + hasWebGPU: 'gpu' in navigator, + hasAtomics: typeof Atomics === 'object', + workerSAB: (() => { + try { return new Worker(URL.createObjectURL(new Blob(['postMessage(typeof SharedArrayBuffer)'], {type:'text/javascript'}))) ? 'spawned' : 'no'; } + catch (e) { return 'err: ' + e.message; } + })(), + coi: { coop: document.featurePolicy?.allowsFeature?.('cross-origin-isolated'), policy: 'see-network' }, + ua: navigator.userAgent, + href: location.href, + })`; + const result = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true }); + const parsed = JSON.parse(result.result.value); + out('probe', parsed); + cdp.close(); +} + +async function modeWatch() { + const target = await pickGmeetTarget(); + console.log(`[diagnose] watch :: ${target.url} (Ctrl-C to stop)`); + const cdp = await attach(target); + await cdp.send('Console.enable'); + await cdp.send('Runtime.enable'); + await cdp.send('Log.enable'); + console.log('[diagnose] subscribed. Click Effects → Background NOW.'); + process.on('SIGINT', () => { + out('watch', cdp.events); + cdp.close(); + process.exit(0); + }); + await new Promise(() => {}); // hang +} + +// URL matchers for `headers` mode. Includes gstatic asset path so the dump +// captures `www.gstatic.com/video_effects/assets/*.mp4` — the requests +// implicated in the dynamic-background failure (#1053 Phase A). +const HEADERS_URL_MATCHERS = [ + (u) => u.includes('meet.google.com'), + (u) => u.includes('gstatic.com/video_effects/assets/'), +]; + +async function modeHeaders() { + const target = await pickGmeetTarget(); + console.log(`[diagnose] headers :: ${target.url}`); + const cdp = await attach(target); + await cdp.send('Network.enable'); + const seen = []; + process.on('SIGINT', () => { + out('headers', seen); + cdp.close(); + process.exit(0); + }); + cdp.events.length = 0; + setInterval(() => { + for (const ev of cdp.events.splice(0)) { + if (ev.method === 'Network.responseReceived') { + const r = ev.params.response; + if (HEADERS_URL_MATCHERS.some((matches) => matches(r.url))) { + seen.push({ + url: r.url, + status: r.status, + mimeType: r.mimeType, + coop: r.headers['Cross-Origin-Opener-Policy'] ?? r.headers['cross-origin-opener-policy'], + coep: r.headers['Cross-Origin-Embedder-Policy'] ?? r.headers['cross-origin-embedder-policy'], + corp: r.headers['Cross-Origin-Resource-Policy'] ?? r.headers['cross-origin-resource-policy'], + }); + console.log(`[diagnose] ${r.status} ${r.url} coop=${seen[seen.length - 1].coop} coep=${seen[seen.length - 1].coep}`); + } + } + } + }, 500); + console.log('[diagnose] capturing meet.google.com + gstatic video_effects responses (Ctrl-C to dump).'); + await new Promise(() => {}); +} + +const mode = argv[2]; +if (mode === 'probe') await modeProbe(); +else if (mode === 'watch') await modeWatch(); +else if (mode === 'headers') await modeHeaders(); +else { + console.error('Usage: diagnose-cef-runtime.mjs '); + process.exit(1); +} diff --git a/src/openhuman/agent/prompts/mod_tests.rs b/src/openhuman/agent/prompts/mod_tests.rs index 404e817b4..62d7760ed 100644 --- a/src/openhuman/agent/prompts/mod_tests.rs +++ b/src/openhuman/agent/prompts/mod_tests.rs @@ -1216,6 +1216,7 @@ fn ctx_with_learned(learned: LearnedContextData) -> PromptContext<'static> { include_profile: false, include_memory_md: false, user_identity: None, + curated_snapshot: None, } }