diff --git a/.env.example b/.env.example index cb1f6860d..24a5beb5d 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,7 @@ BUZZ_DEV_VIEWER= # Packaging embeds the value; changing the launched app's environment has no effect. # Restart Vite for development changes; rebuild/repackage for distributed apps. # BUZZ_BUILDERLAB_URL=https://builderlab.example.com + +# Exact Apps domain paired with that login origin for native viewer SSO. +# Other deployments supply it at build time; empty keeps browser login. +# BUZZ_BUILDERLAB_APPS_DOMAIN=apps.example.com diff --git a/crates/plugin-manager/build.rs b/crates/plugin-manager/build.rs index e5902ec72..e7ea57a3c 100644 --- a/crates/plugin-manager/build.rs +++ b/crates/plugin-manager/build.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; mod build_env; const BUILDERLAB_URL_KEY: &str = "BUZZ_BUILDERLAB_URL"; +const BUILDERLAB_APPS_DOMAIN_KEY: &str = "BUZZ_BUILDERLAB_APPS_DOMAIN"; fn builderlab_origin(local: &Path, process: Option) -> Result { let values = build_env::load(local, &[BUILDERLAB_URL_KEY], |_| process.clone())?; @@ -26,30 +27,122 @@ fn builderlab_origin(local: &Path, process: Option) -> Result, +) -> Result { + let values = build_env::load(local, &[BUILDERLAB_APPS_DOMAIN_KEY], |_| process.clone())?; + let domain = values + .get(BUILDERLAB_APPS_DOMAIN_KEY) + .map(String::as_str) + .unwrap_or(if origin == "https://app.builderlab.xyz" { + "apps.builderlab.xyz" + } else { + "" + }); + if domain.is_empty() { + return Ok(String::new()); + } + let url = url::Url::parse(&format!("https://{domain}")) + .map_err(|_| "Invalid Builderlab Apps build domain")?; + if origin.is_empty() + || !domain.contains('.') + || domain.len() > 253 + || url.domain() != Some(domain) + || domain.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) + { + return Err("Builderlab Apps build domain must be an exact DNS domain with a login origin"); + } + Ok(domain.into()) +} + #[cfg_attr(test, allow(dead_code))] fn main() { let root = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../.."); let local = root.join(".env.local"); println!("cargo:rerun-if-changed={}", local.display()); println!("cargo:rerun-if-env-changed={BUILDERLAB_URL_KEY}"); + println!("cargo:rerun-if-env-changed={BUILDERLAB_APPS_DOMAIN_KEY}"); println!( "cargo:rerun-if-changed={}", root.join("crates/build_env.rs").display() ); - let process = match std::env::var(BUILDERLAB_URL_KEY) { + let process = |key| match std::env::var(key) { Ok(value) => Some(value), Err(std::env::VarError::NotPresent) => None, Err(std::env::VarError::NotUnicode(_)) => panic!("Native build inputs must be UTF-8"), }; - let origin = - builderlab_origin(&local, process).expect("Invalid Builderlab build configuration"); + let origin = builderlab_origin(&local, process(BUILDERLAB_URL_KEY)) + .expect("Invalid Builderlab build configuration"); + let apps_domain = builderlab_apps_domain(&local, &origin, process(BUILDERLAB_APPS_DOMAIN_KEY)) + .expect("Invalid Builderlab Apps build configuration"); println!("cargo:rustc-env=BUZZ_BUILDERLAB_ORIGIN={origin}"); + println!("cargo:rustc-env=BUZZ_BUILDERLAB_APPS_DOMAIN={apps_domain}"); } #[cfg(test)] mod tests { use super::*; + #[test] + fn exact_apps_domain_uses_trusted_build_input_and_public_default() { + let directory = tempfile::tempdir().unwrap(); + let local = directory.path().join(".env.local"); + assert_eq!( + builderlab_apps_domain(&local, "https://app.builderlab.xyz", None).unwrap(), + "apps.builderlab.xyz" + ); + assert_eq!( + builderlab_apps_domain(&local, "https://login.example", None).unwrap(), + "" + ); + std::fs::write(&local, "BUZZ_BUILDERLAB_APPS_DOMAIN=apps.example\n").unwrap(); + assert_eq!( + builderlab_apps_domain(&local, "https://login.example", None).unwrap(), + "apps.example" + ); + assert_eq!( + builderlab_apps_domain( + &local, + "https://login.example", + Some("apps.other.example".into()) + ) + .unwrap(), + "apps.other.example" + ); + assert_eq!( + builderlab_apps_domain(&local, "https://app.builderlab.xyz", Some(String::new())) + .unwrap(), + "" + ); + for domain in [ + "com", + "https://apps.example", + "apps.example:443", + "apps.example/path", + "apps.example?query", + "apps.example#part", + "user@apps.example", + "127.0.0.1", + ".apps.example", + ] { + assert!( + builderlab_apps_domain(&local, "https://login.example", Some(domain.into())) + .is_err() + ); + } + assert!(builderlab_apps_domain(&local, "", Some("apps.example".into())).is_err()); + } + #[test] fn defaults_to_production_unless_explicitly_disabled() { let directory = tempfile::tempdir().unwrap(); @@ -58,9 +151,19 @@ mod tests { builderlab_origin(&local, None).unwrap(), "https://app.builderlab.xyz" ); + assert_eq!( + builderlab_apps_domain(&local, &builderlab_origin(&local, None).unwrap(), None) + .unwrap(), + "apps.builderlab.xyz" + ); assert_eq!(builderlab_origin(&local, Some(String::new())).unwrap(), ""); std::fs::write(&local, "BUZZ_BUILDERLAB_URL=\n").unwrap(); assert_eq!(builderlab_origin(&local, None).unwrap(), ""); + assert_eq!( + builderlab_apps_domain(&local, &builderlab_origin(&local, None).unwrap(), None) + .unwrap(), + "" + ); } #[test] diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 9662d0649..07c2daba9 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -227,6 +227,8 @@ pub fn valid_id(id: &str) -> Result<()> { } /// The `BUZZ_BUILDERLAB_URL` build input's origin, or empty when unconfigured. pub const BUILDERLAB_ORIGIN: &str = env!("BUZZ_BUILDERLAB_ORIGIN"); +/// Exact Apps domain paired with the build's login origin, or empty when native SSO is unconfigured. +pub const BUILDERLAB_APPS_DOMAIN: &str = env!("BUZZ_BUILDERLAB_APPS_DOMAIN"); /// The Apps Platform control plane of the tenant whose Builderlab sign-in /// `origin` is, which the bundled Builderlab plugin deploys apps to. Kept in /// step with `src/bundled/builderlab/apps/environment.ts`. diff --git a/docs/browser.md b/docs/browser.md index 1d74febf4..40ad15443 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -37,6 +37,20 @@ A view with a `storage` also keeps its session for ten minutes after it unmounts Reloading Buzz's main document ends the website session. An attachment queued before that reload cannot replace a session opened by the new document. +Builderlab's bundled app panel passes a `builderlab` viewer lifetime instead of a +shared website sign-in. The browser host uses its credential only for a native +request to the compile-time Builderlab API origin, then installs the restricted +HttpOnly/Secure viewer cookie on the tenant's fixed Apps domain with the server's +absolute expiry. This is the same cookie as browser login, so login/consent repair +replaces it instead of creating a conflicting second cookie. +If the native exchange is unconfigured or its endpoint returns 404, the same +account-isolated guest opens through browser login. Other errors stay closed. +The guest stays blank until installation or this fallback finishes and its account lifetime is checked. No +credential or generic cookie/header capability is exposed to the guest. Each +sign-in selects separate storage; aborting that lifetime closes its live +and parked guests, including a guest whose attach finishes after cancellation. +Normal website views retain the storage behavior above. + ## Native ownership The controls are ordinary trusted React UI in Buzz's main webview. Website content is a separate raw Wry child view positioned over the panel's website viewport, created without a Tauri IPC handler, custom protocols or host initialization scripts. It uses a nonpersistent data store, or the named persistent store described above. The main window's content-security policy is unchanged. diff --git a/docs/configuration.md b/docs/configuration.md index b2d9308f6..89850d7a1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,6 +9,7 @@ Build defaults are readable binary data, **never secret storage**. | Input | Consumer and boundary | | --- | --- | | `BUZZ_BUILDERLAB_URL` | Shared frontend/native build: public login URL and the bundled plugin's native grants; no runtime override. | +| `BUZZ_BUILDERLAB_APPS_DOMAIN` | Shared frontend/native build: exact Apps cookie domain paired with the login origin for native viewer SSO; no runtime override. | | `BUZZ_DEFAULT_COMMUNITY_URL` | Packaged frontend build: secure relay origin to select once, after identity setup, only when this viewer has no saved community record. Saved choices, including Personal space, always win. Unset in OSS builds. | | `BUZZ_DATABRICKS_HOST` | Frontend build and dev server: `https://` Databricks workspace URL passed as `DATABRICKS_HOST` to every bundled `goose-acp`, which then prefers it to the host in goose's config. Unset in OSS builds. See [Databricks host](#databricks-host). | | `BUZZ_RELAY_URL` | Live development broker's default community, not an agent relay override or a packaged default. | @@ -101,6 +102,15 @@ production binding was already allowed before this default. The bundled Builderlab login consumes this public URL and appends `/api/goose`. +Native viewer SSO also needs the exact Apps domain in +`BUZZ_BUILDERLAB_APPS_DOMAIN`, for example `apps.example.com` for a configured +login at `https://login.example.com`. Set both inputs in the same local/build +environment. Only the public production login has a default Apps domain; other +deployments supply their mapping through build configuration. An explicit empty +domain disables native SSO. A missing mapping or an exchange endpoint returning +404 keeps the account-isolated browser-login path. Authentication, permission, +transport and other server errors do not fall back. + Restart desktop development after changing this input so Vite and the native grant rebuild together; browser-only development needs a Vite restart. Production web and desktop frontend builds substitute the value into compiled code: changing `.env.local` diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c2765913e..a409c445e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -96,7 +96,7 @@ objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["NSS objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSObject", "NSThread", "NSBundle", "NSString", "NSError", "NSUserDefaults", "NSProcessInfo"] } objc2-user-notifications = { version = "=0.3.2", default-features = false, features = ["UNUserNotificationCenter", "UNNotificationSettings", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationTrigger", "block2"] } -objc2-web-kit = { version = "=0.3.2", default-features = false, features = ["std", "objc2-app-kit", "block2", "WKWebView", "WKUIDelegate", "WKFrameInfo", "WKSecurityOrigin"] } +objc2-web-kit = { version = "=0.3.2", default-features = false, features = ["std", "objc2-app-kit", "block2", "WKWebView", "WKUIDelegate", "WKFrameInfo", "WKSecurityOrigin", "WKWebViewConfiguration", "WKWebsiteDataStore", "WKHTTPCookieStore"] } [target.'cfg(target_os = "windows")'.dependencies] webview2-com = "=0.39.1" diff --git a/src-tauri/src/browser/builderlab.rs b/src-tauri/src/browser/builderlab.rs new file mode 100644 index 000000000..d14abef3c --- /dev/null +++ b/src-tauri/src/browser/builderlab.rs @@ -0,0 +1,241 @@ +//! Native-only viewer exchange. Credentials never enter a URL or website JavaScript. +use serde::Deserialize; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use url::Url; +use zeroize::Zeroizing; + +const VIEWER_COOKIE: &str = "__Secure-bbidentity_hosted_site_session"; + +#[derive(Deserialize)] +pub struct ViewerAuth { + credential: String, +} + +pub(super) struct Bootstrap { + #[cfg(target_os = "macos")] + pub cookie: Zeroizing, + #[cfg(target_os = "macos")] + pub app_origin: String, + #[cfg(target_os = "macos")] + pub cookie_domain: String, +} + +fn app_root(domain: &str, target: &str) -> Result { + if domain.is_empty() { + return Err("Builderlab Apps sign-in is not configured".into()); + } + let url = Url::parse(target).map_err(|_| "Invalid Builderlab app URL")?; + let label = url + .host_str() + .and_then(|host| host.strip_suffix(&format!(".{domain}"))); + if url.scheme() != "https" + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + || !label.is_some_and(|label| { + !label.is_empty() && !label.contains('.') && !matches!(label, "login" | "health") + }) + { + return Err("This app uses a different Builderlab environment".into()); + } + Ok(format!("https://{}/", url.host_str().unwrap())) +} + +fn cookie( + credential: &str, + expires_at: &str, + domain: &str, + now: i64, +) -> Result, String> { + if !(32..=512).contains(&credential.len()) + || !credential + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"_-".contains(&c)) + { + return Err("Invalid Builderlab viewer credential".into()); + } + let expires_at = chrono::DateTime::parse_from_rfc3339(expires_at) + .map_err(|_| "Invalid Builderlab sign-in expiry")?; + if !(1..=8 * 60 * 60).contains(&(expires_at.timestamp() - now)) { + return Err("Builderlab sign-in expired. Sign in again in Settings".into()); + } + let expiry = expires_at + .with_timezone(&chrono::Utc) + .format("%a, %d %b %Y %H:%M:%S GMT"); + Ok(Zeroizing::new(format!( + "{VIEWER_COOKIE}={credential}; Domain={domain}; Path=/; Expires={expiry}; Secure; HttpOnly; SameSite=Lax" + ))) +} + +/// Only feature absence permits browser-login fallback. All authorization and transport failures stay closed. +fn exchange_available(status: reqwest::StatusCode) -> Result { + if status == reqwest::StatusCode::NOT_FOUND { + return Ok(false); + } + if status.is_success() { + return Ok(true); + } + Err(match status.as_u16() { + 401 => "Sign out and sign in to Builderlab again in Settings, then reopen this app", + 403 => "Your Builderlab account cannot open this app", + _ => "Builderlab viewer sign-in is unavailable. Try reopening this app", + } + .into()) +} + +pub(super) async fn prepare(target: &str, auth: ViewerAuth) -> Result, String> { + let origin = buzzodz_plugins::BUILDERLAB_ORIGIN; + let domain = buzzodz_plugins::BUILDERLAB_APPS_DOMAIN; + let credential = Zeroizing::new(auth.credential); + if domain.is_empty() { + return Ok(None); + } + let root = app_root(domain, target)?; + if !(32..=512).contains(&credential.len()) + || !credential + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"_-".contains(&c)) + { + return Err("Sign in to Builderlab again in Settings".into()); + } + let client = reqwest::Client::builder() + .https_only(true) + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|_| "Builderlab sign-in is unavailable")?; + let body = Zeroizing::new(serde_json::json!({ "return_to": root }).to_string()); + let mut response = client + .post(format!( + "{origin}/api/goose/v1/auth/hosted-site/native-session" + )) + .header("X-BB-Session-Credential", &*credential) + .header("Content-Type", "application/json") + .body(body.to_string()) + .send() + .await + .map_err(|_| "Builderlab sign-in request failed")?; + if !exchange_available(response.status())? { + return Ok(None); + } + let mut bytes = Zeroizing::new(Vec::new()); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "Invalid Builderlab sign-in response")? + { + if bytes.len() + chunk.len() > 4096 { + return Err("Invalid Builderlab sign-in response".into()); + } + bytes.extend_from_slice(&chunk); + } + #[derive(Deserialize)] + struct Reply { + credential: String, + expires_at: String, + } + let reply: Reply = + serde_json::from_slice(&bytes).map_err(|_| "Invalid Builderlab sign-in response")?; + let viewer_credential = Zeroizing::new(reply.credential); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| "Invalid system clock")? + .as_secs() as i64; + let viewer_cookie = cookie(&viewer_credential, &reply.expires_at, domain, now)?; + #[cfg(not(target_os = "macos"))] + { + drop(viewer_cookie); + Err("Builderlab viewer sign-in is supported only on macOS".into()) + } + #[cfg(target_os = "macos")] + Ok(Some(Bootstrap { + cookie: viewer_cookie, + app_origin: root.trim_end_matches('/').into(), + cookie_domain: domain.into(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browser_login_fallback_is_only_for_an_unavailable_feature() { + assert!(!exchange_available(reqwest::StatusCode::NOT_FOUND).unwrap()); + assert!(exchange_available(reqwest::StatusCode::OK).unwrap()); + for code in [400, 401, 403, 429, 500, 503, 302] { + assert!(exchange_available(reqwest::StatusCode::from_u16(code).unwrap()).is_err()); + } + } + + #[test] + fn credentials_are_only_used_for_same_tenant_exact_app_hosts() { + let domain = "apps.example"; + assert_eq!( + app_root( + domain, + "https://counter--bpsites.apps.example/second?q=1#part" + ) + .unwrap(), + "https://counter--bpsites.apps.example/" + ); + for target in [ + "https://counter.apps.other.example/", + "https://apps.example/", + "https://login.apps.example/", + "https://health.apps.example/", + "https://nested.app.apps.example/", + "https://app.apps.example.evil.test/", + "http://counter.apps.example/", + "https://user@counter.apps.example/", + "https://counter.apps.example:8443/", + ] { + assert!(app_root(domain, target).is_err(), "{target}"); + } + assert!(app_root("", "https://counter.apps.example/").is_err()); + } + + #[test] + fn viewer_cookie_replaces_the_existing_domain_cookie_with_absolute_expiry() { + let value = cookie( + &"C".repeat(43), + "2026-10-26T16:00:00Z", + "apps.example", + chrono::DateTime::parse_from_rfc3339("2026-10-26T15:00:00Z") + .unwrap() + .timestamp(), + ) + .unwrap(); + assert!(value.starts_with("__Secure-bbidentity_hosted_site_session=")); + assert!(value.contains( + "Path=/; Expires=Mon, 26 Oct 2026 16:00:00 GMT; Secure; HttpOnly; SameSite=Lax" + )); + assert!(value.contains("Domain=apps.example; Path=/")); + for credential in [ + "short".to_string(), + "C".repeat(513), + format!("{}; injected=x", "C".repeat(43)), + ] { + assert!(cookie( + &credential, + "2026-10-26T16:00:00Z", + "apps.example", + chrono::DateTime::parse_from_rfc3339("2026-10-26T15:00:00Z") + .unwrap() + .timestamp() + ) + .is_err()); + } + for expiry in ["invalid", "2026-10-26T15:00:00Z", "2026-10-27T16:00:00Z"] { + assert!(cookie( + &"C".repeat(43), + expiry, + "apps.example", + chrono::DateTime::parse_from_rfc3339("2026-10-26T15:00:00Z") + .unwrap() + .timestamp() + ) + .is_err()); + } + } +} diff --git a/src-tauri/src/browser/mod.rs b/src-tauri/src/browser/mod.rs index c1449d787..a8ad7fab9 100644 --- a/src-tauri/src/browser/mod.rs +++ b/src-tauri/src/browser/mod.rs @@ -1,5 +1,6 @@ //! Remote website content embedded in the main window without a Tauri bridge. +mod builderlab; mod platform; mod policy; @@ -218,6 +219,7 @@ fn build_contents( target: &str, bounds: BrowserBounds, storage: Option<&str>, + defer_navigation: bool, ) -> Result { let policy = NavigationPolicy::new(app.config().build.dev_url.clone()); let target = policy.validate(target)?; @@ -272,7 +274,9 @@ fn build_contents( policy, }; platform::resize_guest(&browser.guest, &browser.host, bounds)?; - browser.navigate(target.as_str())?; + if !defer_navigation { + browser.navigate(target.as_str())?; + } Ok(browser) } @@ -282,12 +286,23 @@ pub async fn browser_attach( url: String, bounds: BrowserBounds, storage: Option, + builderlab: Option, ) -> Result { let generation = DOCUMENT_GENERATION.load(Ordering::SeqCst); + let defer_navigation = builderlab.is_some(); + let bootstrap = match builderlab { + Some(auth) => builderlab::prepare(&url, auth).await?, + None => None, + }; let handle = app.clone(); - on_main_thread(&app, move || { + let (session_id, pending) = on_main_thread(&app, move || { with_current_document(&DOCUMENT_GENERATION, generation, || { - let browser = build_contents(&handle, &url, bounds, storage.as_deref())?; + let browser = + build_contents(&handle, &url, bounds, storage.as_deref(), defer_navigation)?; + let pending = bootstrap + .as_ref() + .map(|value| platform::install_builderlab_cookie(&browser.guest, value)) + .transpose()?; let session_id = browser.instance.clone(); BROWSERS.with(|browsers| { let mut browsers = browsers.borrow_mut(); @@ -295,10 +310,38 @@ pub async fn browser_attach( let excess = browsers.len().saturating_sub(MAX_GUESTS); browsers.drain(..excess); }); - Ok(session_id) + Ok((session_id, pending)) }) }) - .await + .await?; + if let Some(mut pending) = pending { + let installed = + tokio::time::timeout(std::time::Duration::from_secs(5), pending.recv()).await; + let id = session_id.clone(); + on_main_thread(&app, move || { + if !matches!(installed, Ok(Some(()))) { + BROWSERS.with(|browsers| { + browsers + .borrow_mut() + .retain(|browser| browser.instance != id) + }); + return Err("Builderlab viewer cookie could not be installed".into()); + } + let result = with_current_document(&DOCUMENT_GENERATION, generation, || { + with_browser(&id, |_| Ok(())) + }); + if result.is_err() { + BROWSERS.with(|browsers| { + browsers + .borrow_mut() + .retain(|browser| browser.instance != id) + }); + } + result + }) + .await?; + } + Ok(session_id) } #[tauri::command] diff --git a/src-tauri/src/browser/platform.rs b/src-tauri/src/browser/platform.rs index 4992512bf..2f4e4b641 100644 --- a/src-tauri/src/browser/platform.rs +++ b/src-tauri/src/browser/platform.rs @@ -71,6 +71,59 @@ mod macos { >= 14 } + fn builderlab_viewer_cookie( + bootstrap: &super::super::builderlab::Bootstrap, + ) -> Result, String> { + use objc2_foundation::{ns_string, NSDictionary, NSHTTPCookie, NSString, NSURL}; + let origin = NSURL::URLWithString(&NSString::from_str(&bootstrap.app_origin)) + .ok_or("Invalid Builderlab app origin")?; + let headers = NSDictionary::from_slices( + &[ns_string!("Set-Cookie")], + &[&*NSString::from_str(&bootstrap.cookie)], + ); + let cookies = NSHTTPCookie::cookiesWithResponseHeaderFields_forURL(&headers, &origin); + let cookie = cookies + .firstObject() + .ok_or("Builderlab viewer cookie is unavailable")?; + let host = + url::Url::parse(&bootstrap.app_origin).map_err(|_| "Invalid Builderlab app origin")?; + if cookies.count() != 1 + || cookie.name().to_string() != "__Secure-bbidentity_hosted_site_session" + || !cookie.isHTTPOnly() + || !cookie.isSecure() + || cookie.expiresDate().is_none() + || cookie.domain().to_string().trim_start_matches('.') != bootstrap.cookie_domain + || !host + .host_str() + .is_some_and(|value| value.ends_with(&format!(".{}", bootstrap.cookie_domain))) + || cookie.path().to_string() != "/" + { + return Err("Builderlab viewer cookie is unavailable".into()); + } + Ok(cookie) + } + + /// Installs only the restricted native viewer cookie; never reads cookies or accepts caller cookie fields. + pub fn install_builderlab_cookie( + guest: &WebView, + bootstrap: &super::super::builderlab::Bootstrap, + ) -> Result, String> { + let cookie = builderlab_viewer_cookie(bootstrap)?; + let (sender, receiver) = tokio::sync::mpsc::channel(1); + let complete = block2::RcBlock::new(move || { + let _ = sender.try_send(()); + }); + unsafe { + guest + .webview() + .configuration() + .websiteDataStore() + .httpCookieStore() + .setCookie_completionHandler(&cookie, Some(&complete)); + } + Ok(receiver) + } + pub fn loading(guest: &WebView) -> bool { unsafe { guest.webview().isLoading() } } @@ -109,6 +162,40 @@ mod macos { } Ok(()) } + #[cfg(test)] + mod binding_tests { + use super::*; + #[test] + fn viewer_cookie_matches_existing_domain_http_only_secure_and_expiry() { + // Foundation caps far-future cookies. Use a short future date while asserting exact parsing, + // rather than testing the engine's unrelated long-term cookie retention policy. + let expiry = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + + 60; + let date = chrono::DateTime::::from_timestamp(expiry, 0).unwrap(); + let date = date.format("%a, %d %b %Y %H:%M:%S GMT"); + let value = super::super::super::builderlab::Bootstrap { + app_origin: "https://counter--bpsites.apps.example".into(), + cookie_domain: "apps.example".into(), + cookie: zeroize::Zeroizing::new(format!("__Secure-bbidentity_hosted_site_session=viewer; Domain=apps.example; Path=/; Expires={date}; Secure; HttpOnly; SameSite=Lax")), + }; + let cookie = builderlab_viewer_cookie(&value).unwrap(); + assert_eq!( + cookie.domain().to_string().trim_start_matches('.'), + "apps.example" + ); + assert_eq!(cookie.path().to_string(), "/"); + assert!(cookie.isHTTPOnly()); + assert!(cookie.isSecure()); + assert!(!cookie.isSessionOnly()); + assert_eq!( + cookie.expiresDate().unwrap().timeIntervalSince1970(), + expiry as f64 + ); + } + } } #[cfg(target_os = "macos")] @@ -192,7 +279,9 @@ pub(super) fn resize_guest( } #[cfg(target_os = "macos")] -pub(super) use macos::{back, current_url, deny_permissions, focused, forward, loading}; +pub(super) use macos::{ + back, current_url, deny_permissions, focused, forward, install_builderlab_cookie, loading, +}; #[cfg(target_os = "macos")] pub(super) type Permissions = objc2::rc::Retained; @@ -332,3 +421,11 @@ pub(super) fn forward(guest: &WebView) -> Result<(), String> { .map_err(|error| error.to_string()) } } + +#[cfg(not(target_os = "macos"))] +pub(super) fn install_builderlab_cookie( + _guest: &WebView, + _bootstrap: &super::builderlab::Bootstrap, +) -> Result, String> { + Err("Builderlab viewer sign-in is supported only on macOS".into()) +} diff --git a/src/bundled/builderlab/README.md b/src/bundled/builderlab/README.md index b42cf5598..3c6eeb549 100644 --- a/src/bundled/builderlab/README.md +++ b/src/bundled/builderlab/README.md @@ -35,11 +35,21 @@ again. A failed reservation or initialization names the planned app ID; inspect resume that app before creating another one. Automatic reservation recovery and hosted tool declarations are separate work. -The app panel uses the existing native browser and persistent website store on -macOS 14+. Website login is separate from Settings login, including sign-out and -account switching. Confirm the website account when testing. Earlier macOS uses a -private store; other platforms do not expose the embedded app panel. CLI-to-site -SSO is a separate integration. +The macOS app panel uses the Settings account for viewing. Native code exchanges +its CLI credential on the configured Builderlab API for a restricted viewer +credential and absolute expiry. Native code installs the HttpOnly/Secure viewer +cookie before opening the original app URL. The guest never receives the CLI +credential. App authorization and consent still run at ingress. +Opening a different tenant's app never forwards the current account's credential. + +Each sign-in has separate website storage. Signing out or changing accounts closes +live and retained guests; deliberate sign-out also requests revocation of the server CLI parent. +Canceling sign-in or disposing the plugin closes its local guests without logging +out an existing server session. Other platforms do not expose the app panel. +The server's native viewer feature must be enabled after its schema and auth +review; older CLI sessions without signed authentication time require a fresh +Settings login. This client change alone does not enable the server feature. Apps that require +pre-app consent still use the existing canonical browser authentication gate. ## Remote agents diff --git a/src/bundled/builderlab/apps/environment.test.ts b/src/bundled/builderlab/apps/environment.test.ts new file mode 100644 index 000000000..b61373747 --- /dev/null +++ b/src/bundled/builderlab/apps/environment.test.ts @@ -0,0 +1,38 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { isAppUrl, isViewerTarget, viewerAppDomain } from "./environment"; + +afterEach(() => vi.unstubAllEnvs()); + +it("uses the exact Apps domain paired with the trusted login build configuration", () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://login.example"); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_APPS_DOMAIN", "apps.example"); + expect(viewerAppDomain()).toBe("apps.example"); + expect(isAppUrl("https://counter--workspace.apps.example/report")).toBe(true); + expect( + isViewerTarget("https://counter--workspace.apps.example/report?q=1#part"), + ).toBe(true); + for (const target of [ + "https://counter.apps.other.example/", + "https://counter.apps.example.evil.example/", + "https://nested.counter.apps.example/", + "https://apps.example/", + "https://login.apps.example/", + "https://health.apps.example/", + "http://counter.apps.example/", + "https://user@counter.apps.example/", + "https://counter.apps.example:8443/", + ]) + expect(isViewerTarget(target)).toBe(false); +}); + +it("keeps native SSO off without an Apps domain and honors an explicit disable", () => { + expect(viewerAppDomain("https://login.example", undefined)).toBe(""); + expect(viewerAppDomain("https://app.builderlab.xyz", "")).toBe(""); + expect( + isViewerTarget( + "https://counter.apps.example/", + "https://login.example", + "", + ), + ).toBe(false); +}); diff --git a/src/bundled/builderlab/apps/environment.ts b/src/bundled/builderlab/apps/environment.ts index 0bce7cf5d..df10c5fd3 100644 --- a/src/bundled/builderlab/apps/environment.ts +++ b/src/bundled/builderlab/apps/environment.ts @@ -45,10 +45,60 @@ export function isAppUrl(target: string) { return false; } const host = url.hostname.toLowerCase(); + const viewerDomain = viewerAppDomain(); return ( url.protocol === "https:" && !url.username && !url.password && - APP_DOMAINS.test(host) + (APP_DOMAINS.test(host) || + (!!viewerDomain && host.endsWith(`.${viewerDomain}`))) ); } + +/** Trusted build configuration, paired with the configured login origin. Empty disables native SSO. */ +export function viewerAppDomain( + origin = import.meta.env.VITE_BUZZ_BUILDERLAB_URL ?? "", + configured: string | undefined = import.meta.env + .VITE_BUZZ_BUILDERLAB_APPS_DOMAIN, +): string { + if (configured !== undefined) return configured; + try { + return new URL(origin).origin === "https://app.builderlab.xyz" + ? "apps.builderlab.xyz" + : ""; + } catch { + return ""; + } +} + +/** A credential is only exchanged for app hosts in the same configured tenant. */ +export function isViewerTarget( + target: string, + origin = import.meta.env.VITE_BUZZ_BUILDERLAB_URL ?? "", + domain = viewerAppDomain(origin), +) { + try { + const url = new URL(target); + const login = new URL(origin); + const label = domain && url.hostname.slice(0, -(domain.length + 1)); + return ( + login.protocol === "https:" && + !login.username && + !login.password && + url.protocol === "https:" && + !url.username && + !url.password && + Boolean( + domain && + url.port === "" && + url.hostname.endsWith(`.${domain}`) && + label && + !label.includes(".") && + label !== "login" && + label !== "health", + ) + ); + } catch { + return false; + } +} diff --git a/src/bundled/builderlab/apps/index.test.ts b/src/bundled/builderlab/apps/index.test.ts index 44a7cf53c..fdd120344 100644 --- a/src/bundled/builderlab/apps/index.test.ts +++ b/src/bundled/builderlab/apps/index.test.ts @@ -55,7 +55,7 @@ async function setup( host: { request: respond, upload: respond }, agents: { addTool: (tool: AddedTool) => tools.set(tool.name, tool) }, }; - applyApps(ctx as never, session); + applyApps(ctx as never, session, true); const call = (name: string, args: Record, context = {}) => { const tool = tools.get(name); if (!tool) throw new Error(`No tool ${name}`); diff --git a/src/bundled/builderlab/apps/index.ts b/src/bundled/builderlab/apps/index.ts index db7f8dfd3..fddcd273f 100644 --- a/src/bundled/builderlab/apps/index.ts +++ b/src/bundled/builderlab/apps/index.ts @@ -1,12 +1,20 @@ // Builderlab Apps for local agents: tools that create, deploy and share a // web app with the plugin's Builderlab sign-in, and a side panel that // shows a deployed app in Buzz's browser view. -import { createElement } from "react"; +import { createElement, useSyncExternalStore } from "react"; import type { PluginModule } from "../../../plugins/api"; import type { PanelProps } from "../../../features/panels/service"; import type { OAuthSession } from "../oauth/session"; import { createAppsClient, type RuntimeProfile } from "./client"; -import { appName, controlPlane, isAppUrl } from "./environment"; +import { + appName, + controlPlane, + isAppUrl, + isViewerTarget, + viewerAppDomain, +} from "./environment"; + +import { Login } from "../login/Login"; type Ctx = Parameters[0]; /** How long `apps_deploy` waits for the new version to serve. */ @@ -34,23 +42,52 @@ const DEPLOY_RECIPE = `Deploy a Builderlab app. Read apps_contract before choosi 4. Call this with the archive's absolute path; it must be in your owner's home directory. It returns the version/deployment IDs once accepted, even if readiness checking fails. Use apps_status for that exact version; do not redeploy to retry readiness. Redeploy the same app_id after source changes; a validation error names what to fix in the archive.`; -export function applyApps(ctx: Ctx, session: OAuthSession) { +export function applyApps( + ctx: Ctx, + session: OAuthSession, + /** Whether Settings can sign in; otherwise the app's own browser login is used. */ + loginAvailable: boolean, +) { // Cordis wraps service functions on access; keep React's component identity. const View = ctx.browser.View; + const AppView = ({ target }: PanelProps) => { + const state = useSyncExternalStore( + session.subscribe, + session.snapshot, + session.snapshot, + ); + // Unconfigured login and other tenants keep their own browser login. + // Only this build's exact app tenant may receive the Settings credential. + if (!loginAvailable || (viewerAppDomain() && !isViewerTarget(target))) + return createElement(View, { + url: target, + storage: "builderlab-apps", + label: appName(target), + }); + if (state.status !== "signed-in") + return createElement( + "div", + { className: "p-panel-inset" }, + createElement(Login, { + session, + cancelOnUnmount: false, + available: true, + active: () => true, + }), + ); + return createElement(View, { + url: target, + builderlab: session.viewerSession(), + canResumeUrl: viewerAppDomain() ? isViewerTarget : undefined, + label: appName(target), + }); + }; ctx.panels.register({ id: "app", title: "App", - // Each app keeps its own tab while the conversation moves on. keepOpen: true, matches: (target) => ctx.browser.available && isAppUrl(target), - component: ({ target }: PanelProps) => - // One storage for every app keeps the apps sign-in, which covers all - // of a tenant's app hosts, between opens and restarts. - createElement(View, { - url: target, - storage: "builderlab-apps", - label: appName(target), - }), + component: AppView, }); const base = controlPlane(); if (!base) return; diff --git a/src/bundled/builderlab/apps/viewer.test.tsx b/src/bundled/builderlab/apps/viewer.test.tsx new file mode 100644 index 000000000..eae1ed748 --- /dev/null +++ b/src/bundled/builderlab/apps/viewer.test.tsx @@ -0,0 +1,185 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { createElement, type ComponentType } from "react"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { BrowserViewProps } from "../../../features/browser/api"; +import type { PanelProps } from "../../../features/panels/service"; +import { createOAuthSession } from "../oauth/session"; +import { applyApps } from "./index"; +import { deferred } from "../test-helpers"; +import type { Credential } from "../oauth/browser"; + +const APP = "https://counter--workspace.apps.example/report/42"; +afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); +}); + +function setup(loginAvailable = true) { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://login.example"); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_APPS_DOMAIN", "apps.example"); + const acquire = vi.fn(async () => ({ + value: "first-private-token", + account: { + subject: "first", + email: "first@example.com", + canUseRemoteAgents: false, + }, + })); + const revoke = vi.fn(); + const session = createOAuthSession(acquire, revoke); + const viewed = vi.fn((_props: BrowserViewProps) => + createElement("div", { "aria-label": "App website" }), + ); + const register = vi.fn(); + const ctx = { + browser: { available: true, View: viewed }, + panels: { + register, + requestOpen: vi.fn(() => true), + }, + agents: { addTool: vi.fn() }, + host: {}, + }; + applyApps(ctx as never, session, loginAvailable); + const panel = register.mock.calls[0]?.[0] as { + matches(target: string): boolean; + component: ComponentType; + }; + return { session, acquire, revoke, viewed, panel }; +} + +it("app links and apps_open use the Settings account and replace its guest lifetime on account change", async () => { + const { session, acquire, revoke, viewed, panel } = setup(); + await session.signIn(); + expect(panel.matches(APP)).toBe(true); + render(createElement(panel.component, { target: APP } as PanelProps)); + expect(screen.getByLabelText("App website")).toBeInTheDocument(); + const first = viewed.mock.lastCall?.[0].builderlab; + expect(first?.credential).toBe("first-private-token"); + expect(viewed.mock.lastCall?.[0].url).toBe(APP); + act(() => session.signOut()); + expect(first?.signal.aborted).toBe(true); + expect(screen.queryByLabelText("App website")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Sign in with Builderlab" }), + ).toBeInTheDocument(); + acquire.mockResolvedValueOnce({ + value: "second-private-token", + account: { + subject: "second", + email: "second@example.com", + canUseRemoteAgents: false, + }, + }); + await act(() => session.signIn()); + expect(viewed.mock.lastCall?.[0].builderlab?.credential).toBe( + "second-private-token", + ); + expect(viewed.mock.lastCall?.[0].builderlab?.storage).not.toBe( + first?.storage, + ); + expect(revoke).toHaveBeenCalledTimes(1); + session.dispose(); +}); + +it("restores only app destinations in the configured tenant", async () => { + const { session, viewed, panel } = setup(); + await session.signIn(); + render(createElement(panel.component, { target: APP } as PanelProps)); + const canResume = viewed.mock.lastCall?.[0].canResumeUrl; + expect( + canResume?.("https://second--workspace.apps.example/report?q=1#part"), + ).toBe(true); + for (const destination of [ + "https://login.apps.example/api/goose/v1/auth/hosted-site/handoff?code=one-use", + "https://login.example/hosted-apps/consent?ticket=one-use", + "https://second.apps.other.example/report", + "https://nested.second.apps.example/report", + "https://second.apps.example:8443/report", + "http://second.apps.example/report", + "https://user@second.apps.example/report", + ]) + expect(canResume?.(destination)).toBe(false); + session.dispose(); +}); + +it("closing an observing app panel does not cancel Settings' pending login", async () => { + const { session, acquire, viewed, panel } = setup(); + const held = deferred(); + acquire.mockImplementationOnce(() => held.promise); + const first = render( + createElement(panel.component, { target: APP } as PanelProps), + ); + const second = render( + createElement(panel.component, { target: APP } as PanelProps), + ); + let login!: Promise; + act(() => { + login = session.signIn(); + }); + expect(session.snapshot().status).toBe("pending"); + first.unmount(); + expect(session.snapshot().status).toBe("pending"); + await act(async () => { + held.resolve({ + value: "private", + account: { subject: "owner", email: "", canUseRemoteAgents: false }, + }); + await login; + }); + expect(viewed.mock.lastCall?.[0].builderlab?.credential).toBe("private"); + second.unmount(); + session.dispose(); +}); + +it("an app from a different tenant keeps browser login without the Settings credential", async () => { + const { session, viewed, panel } = setup(); + await session.signIn(); + render( + createElement(panel.component, { + target: "https://counter.apps.other.example/", + } as PanelProps), + ); + expect(screen.getByLabelText("App website")).toBeInTheDocument(); + expect(viewed.mock.lastCall?.[0]).toMatchObject({ + url: "https://counter.apps.other.example/", + storage: "builderlab-apps", + }); + expect(viewed.mock.lastCall?.[0].builderlab).toBeUndefined(); + session.dispose(); +}); + +it("an unconfigured native exchange keeps the account-isolated browser-login path", async () => { + const { session, viewed, panel } = setup(); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_APPS_DOMAIN", ""); + const target = "https://counter--workspace.apps.builderlab.xyz/report"; + await session.signIn(); + expect(panel.matches(target)).toBe(true); + render(createElement(panel.component, { target } as PanelProps)); + expect(screen.getByLabelText("App website")).toBeInTheDocument(); + const lifetime = viewed.mock.lastCall?.[0].builderlab; + expect(viewed.mock.lastCall?.[0].canResumeUrl).toBeUndefined(); + expect(lifetime?.storage).toBeTruthy(); + act(() => session.signOut()); + expect(lifetime?.signal.aborted).toBe(true); + expect(screen.queryByLabelText("App website")).not.toBeInTheDocument(); + session.dispose(); +}); + +it("opens the app for its own browser login when Settings login is unavailable", () => { + const { session, acquire, viewed, panel } = setup(false); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", ""); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_APPS_DOMAIN", ""); + render(createElement(panel.component, { target: APP } as PanelProps)); + expect(screen.getByLabelText("App website")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Sign in with Builderlab" }), + ).not.toBeInTheDocument(); + const props = viewed.mock.lastCall?.[0]; + expect(props?.builderlab).toBeUndefined(); + expect(props?.storage).toBe("builderlab-apps"); + expect(acquire).not.toHaveBeenCalled(); + session.dispose(); +}); diff --git a/src/bundled/builderlab/index.tsx b/src/bundled/builderlab/index.tsx index 1115d7bb0..3662855b4 100644 --- a/src/bundled/builderlab/index.tsx +++ b/src/bundled/builderlab/index.tsx @@ -38,13 +38,28 @@ export const apply: PluginModule["apply"] = (ctx) => { ? error.message : "Builderlab sign-in is unavailable."; } - const session = createOAuthSession((signal) => { - if (!browserLoginAvailable()) - throw new Error("Open the Buzz desktop app to sign in with Builderlab."); - return browserCredential(ctx.host, signal); - }); + const session = createOAuthSession( + (signal) => { + if (!browserLoginAvailable()) + throw new Error( + "Open the Buzz desktop app to sign in with Builderlab.", + ); + return browserCredential(ctx.host, signal); + }, + (credential) => { + // Local guest teardown is immediate; the parent logout also revokes every server-side viewer child. + void ctx.host + .request({ + url: `${oauthTarget()}/v1/auth/logout`, + method: "POST", + headers: { "X-BB-Session-Credential": credential.value }, + }) + .catch(() => {}); + }, + ); ctx.effect(() => () => session.dispose()); - applyApps(ctx, session); + const loginAvailable = browserLoginAvailable() && !unavailable; + applyApps(ctx, session, loginAvailable); const backupSession = () => { if (!nativeIdentityEnabled()) return; handoffBackupSession( @@ -73,7 +88,6 @@ export const apply: PluginModule["apply"] = (ctx) => { }), ); const agents = createAgentClient(ctx.host, session); - const loginAvailable = browserLoginAvailable() && !unavailable; if (!unavailable) { let providerState: AgentProviderSnapshot = { readiness: "attention", diff --git a/src/bundled/builderlab/login/Login.tsx b/src/bundled/builderlab/login/Login.tsx index e6dcfabef..a7ab69de6 100644 --- a/src/bundled/builderlab/login/Login.tsx +++ b/src/bundled/builderlab/login/Login.tsx @@ -8,12 +8,15 @@ export function Login({ available, active, showHeading = true, + cancelOnUnmount = true, unavailableReason = "Open the Buzz desktop app to sign in with Builderlab.", }: { session: OAuthSession; available: boolean; active(): boolean; showHeading?: boolean; + /** Settings owns its pending login; shared app panels only observe it. */ + cancelOnUnmount?: boolean; unavailableReason?: string; }) { const state = useSyncExternalStore( @@ -23,9 +26,10 @@ export function Login({ ); useEffect( () => () => { - if (session.snapshot().status === "pending") session.cancel(); + if (cancelOnUnmount && session.snapshot().status === "pending") + session.cancel(); }, - [session], + [cancelOnUnmount, session], ); return (
diff --git a/src/bundled/builderlab/oauth/session.test.ts b/src/bundled/builderlab/oauth/session.test.ts index 1fab5bc1f..9d8486505 100644 --- a/src/bundled/builderlab/oauth/session.test.ts +++ b/src/bundled/builderlab/oauth/session.test.ts @@ -106,3 +106,37 @@ it("creation joins an existing Settings login and aborts its wait without cancel await session.ensureSignedIn(new AbortController().signal); expect(acquire).toHaveBeenCalledTimes(1); }); + +it("gives each sign-in a separate viewer lifetime and revokes only deliberate sign-out or replacement", async () => { + const revoke = vi.fn(); + const session = createOAuthSession(async () => credential, revoke); + await session.signIn(); + const first = session.viewerSession(); + expect(first.credential).toBe(credential.value); + expect(JSON.stringify(session.snapshot())).not.toContain(credential.value); + session.cancel(); + expect(first.signal.aborted).toBe(true); + expect(revoke).not.toHaveBeenCalled(); + await session.signIn(); + const second = session.viewerSession(); + expect(second.storage).not.toBe(first.storage); + session.signOut(); + expect(second.signal.aborted).toBe(true); + expect(revoke).toHaveBeenCalledExactlyOnceWith(credential); + await session.signIn(); + const third = session.viewerSession(); + session.dispose(); + expect(third.signal.aborted).toBe(true); + expect(revoke).toHaveBeenCalledTimes(1); +}); + +it("ensureSignedIn starts and joins a fresh Settings login from signed-out", async () => { + const login = deferred(); + const session = createOAuthSession(() => login.promise); + const pending = session.ensureSignedIn(new AbortController().signal); + expect(session.snapshot().status).toBe("pending"); + login.resolve(credential); + await expect(pending).resolves.toBeUndefined(); + expect(session.viewerSession().signal.aborted).toBe(false); + session.dispose(); +}); diff --git a/src/bundled/builderlab/oauth/session.ts b/src/bundled/builderlab/oauth/session.ts index f763c273e..5edab61b0 100644 --- a/src/bundled/builderlab/oauth/session.ts +++ b/src/bundled/builderlab/oauth/session.ts @@ -1,3 +1,4 @@ +import type { BuilderlabViewerSession } from "../../../features/browser/api"; import type { Credential } from "./browser"; export type LoginSnapshot = Readonly<{ @@ -9,9 +10,12 @@ export type LoginSnapshot = Readonly<{ /** One plugin lifetime owns the reusable credential; UI snapshots never contain it. */ export function createOAuthSession( acquire: (signal: AbortSignal) => Promise, + release?: (credential: Credential) => void, ) { let credential: Credential | undefined; let attempt: AbortController | undefined; + let viewer: BuilderlabViewerSession | undefined; + let viewerLifetime: AbortController | undefined; let disposed = false; let state: LoginSnapshot = { status: "signed-out" }; const listeners = new Set<() => void>(); @@ -19,11 +23,16 @@ export function createOAuthSession( state = next; for (const listener of listeners) listener(); }; - const reset = () => { + const reset = (revoke = false, announce = true) => { attempt?.abort(); attempt = undefined; + viewerLifetime?.abort(); + viewerLifetime = undefined; + viewer = undefined; + const previous = credential; credential = undefined; - publish({ status: "signed-out" }); + if (previous && revoke) release?.(previous); + if (announce) publish({ status: "signed-out" }); }; return { snapshot: () => state, @@ -37,6 +46,10 @@ export function createOAuthSession( if (!credential) throw new Error("Sign in to Builderlab first."); return credential; }, + viewerSession() { + if (!viewer) throw new Error("Sign in to Builderlab first."); + return viewer; + }, /** Creation joins Settings' login without owning or canceling that attempt. */ async ensureSignedIn(signal: AbortSignal): Promise { signal.throwIfAborted(); @@ -62,11 +75,11 @@ export function createOAuthSession( void this.signIn(); }); }, - cancel: reset, - signOut: reset, + cancel: () => reset(), + signOut: () => reset(true), async signIn() { if (disposed || attempt) return; - credential = undefined; + reset(true, false); const current = new AbortController(); attempt = current; publish({ status: "pending" }); @@ -75,6 +88,12 @@ export function createOAuthSession( current.signal.throwIfAborted(); if (attempt !== current || disposed) return; credential = result; + viewerLifetime = new AbortController(); + viewer = { + credential: result.value, + storage: `builderlab-${crypto.randomUUID()}`, + signal: viewerLifetime.signal, + }; publish({ status: "signed-in", account: result.account }); } catch (error) { if (attempt !== current || disposed) return; diff --git a/src/features/browser/BrowserHostView.test.tsx b/src/features/browser/BrowserHostView.test.tsx index b65938bf6..79c84e175 100644 --- a/src/features/browser/BrowserHostView.test.tsx +++ b/src/features/browser/BrowserHostView.test.tsx @@ -421,6 +421,7 @@ it("retries a failed evicted guest at its last page only on explicit Reload", as "https://first.example/signed-in", expect.any(Object), undefined, + undefined, ), ); expect(await screen.findByRole("alert")).toHaveTextContent( @@ -454,6 +455,7 @@ it("retries a failed evicted guest at its last page only on explicit Reload", as "https://first.example/signed-in", expect.any(Object), undefined, + undefined, ); expect(screen.queryByRole("alert")).toBeNull(); expect(reload).toHaveFocus(); @@ -836,3 +838,138 @@ it("ignores status from a replaced session", async () => { await waitFor(() => expect(screen.getByTitle("New page")).toBeVisible()); expect(screen.queryByText("Old page")).not.toBeInTheDocument(); }); + +it.each([false, true])( + "restarts a lost Builderlab guest at the app URL instead of replaying its login ticket (resume policy: %s)", + async (withPolicy) => { + const platform = fakePlatform(); + let count = 0; + vi.mocked(platform.attach).mockImplementation( + async () => `guest-${++count}`, + ); + const login = + "https://login.apps.builderlab.xyz/api/goose/v1/auth/hosted-site/handoff?code=one-use"; + vi.mocked(platform.status).mockResolvedValue({ + url: login, + title: "", + loading: true, + error: null, + }); + const sessions = new BrowserSessions(platform); + const lifetime = new AbortController(); + const builderlab = { + credential: "private", + storage: "viewer-test", + signal: lifetime.signal, + }; + const app = "https://counter.apps.builderlab.xyz/report/42"; + render( + false : undefined} + platform={platform} + sessions={sessions} + />, + ); + // Establish that polling applied the intermediate navigation before losing this guest. + await waitFor(() => + expect(screen.getByRole("textbox", { name: "Address" })).toHaveValue( + login, + ), + ); + await act(() => sessions.detach("guest-1")); + fireEvent(window, new Event("resize")); + await waitFor(() => expect(platform.attach).toHaveBeenCalledTimes(2)); + expect(platform.attach).toHaveBeenLastCalledWith( + app, + expect.any(Object), + "viewer-test", + builderlab, + ); + lifetime.abort(); + }, +); + +it("restores the last approved app after eviction instead of a later consent ticket", async () => { + const platform = fakePlatform(); + let count = 0; + vi.mocked(platform.attach).mockImplementation(async () => `guest-${++count}`); + const destination = "https://second.apps.example/report/42?q=week#chart"; + vi.mocked(platform.status).mockResolvedValue({ + url: destination, + title: "Second app", + loading: false, + error: null, + }); + const sessions = new BrowserSessions(platform); + const lifetime = new AbortController(); + const builderlab = { + credential: "private", + storage: "viewer-test", + signal: lifetime.signal, + }; + const canResumeUrl = vi.fn((target: string) => target === destination); + const view = render( +
+ +
, + ); + await waitFor(() => + expect(screen.getByRole("textbox", { name: "Address" })).toHaveValue( + destination, + ), + ); + const consent = "https://login.example/consent?ticket=one-use"; + vi.mocked(platform.status).mockResolvedValue({ + url: consent, + title: "Consent", + loading: true, + error: null, + }); + await waitFor(() => + expect(screen.getByRole("textbox", { name: "Address" })).toHaveValue( + consent, + ), + ); + // Hide it, then fill the real four-guest pool so its oldest guest is evicted. + view.getByTestId("pane").setAttribute("data-covered", ""); + await waitFor(() => + expect(platform.setBounds).toHaveBeenCalledWith( + "guest-1", + expect.any(Object), + false, + ), + ); + await act(async () => { + for (let index = 0; index < 4; index++) + await sessions.attach( + `https://ordinary${index}.example/`, + { + x: 0, + y: 0, + width: 400, + height: 300, + }, + () => true, + ); + }); + expect(platform.detach).toHaveBeenCalledWith("guest-1"); + view.getByTestId("pane").removeAttribute("data-covered"); + await waitFor(() => expect(platform.attach).toHaveBeenCalledTimes(6)); + expect(platform.attach).toHaveBeenLastCalledWith( + destination, + expect.any(Object), + "viewer-test", + builderlab, + ); + expect(canResumeUrl).toHaveBeenCalledWith(destination); + expect(canResumeUrl).toHaveBeenCalledWith(consent); + lifetime.abort(); +}); diff --git a/src/features/browser/BrowserHostView.tsx b/src/features/browser/BrowserHostView.tsx index 3187216ae..9322f3601 100644 --- a/src/features/browser/BrowserHostView.tsx +++ b/src/features/browser/BrowserHostView.tsx @@ -89,7 +89,9 @@ function hostOverlayOpen(viewport: BrowserBounds): boolean { export function BrowserHostView({ url, - storage, + storage: requestedStorage, + builderlab, + canResumeUrl, label, platform, sessions, @@ -99,6 +101,7 @@ export function BrowserHostView({ sessions: BrowserSessions; pollInterval?: number; }) { + const storage = builderlab?.storage ?? requestedStorage; const viewport = useRef(null); const session = useRef(undefined); const retryAttach = useRef<(() => void) | undefined>(undefined); @@ -214,7 +217,14 @@ export function BrowserHostView({ try { const next = await platform.status(sessionId); if (!disposed && session.current === sessionId) { - if (next.url) resumeUrl.current = next.url; + // Restore app destinations, not temporary auth/consent pages or their one-use tickets. + if ( + next.url && + (canResumeUrl + ? canResumeUrl(next.url) + : !builderlab || new URL(next.url).origin === new URL(url).origin) + ) + resumeUrl.current = next.url; sessions.rememberTitle(titleKey, next.title); setStatus(next); } @@ -233,7 +243,7 @@ export function BrowserHostView({ }; const begin = () => { - if (attachStarted || disposed) return; + if (attachStarted || disposed || builderlab?.signal.aborted) return; const initialBounds = boundsFor(element); if (!visible(initialBounds)) return; attachStarted = true; @@ -245,6 +255,7 @@ export function BrowserHostView({ () => !disposed, storage, park, + builderlab, ) .then((sessionId) => { if (!sessionId || disposed) return; @@ -343,7 +354,16 @@ export function BrowserHostView({ // say from another channel, so work in progress there survives. if (sessionId) void sessions.detach(sessionId, park); }; - }, [platform, pollInterval, sessions, storage, titleKey, url]); + }, [ + builderlab, + canResumeUrl, + platform, + pollInterval, + sessions, + storage, + titleKey, + url, + ]); async function run(operation: (sessionId: string) => Promise) { const sessionId = session.current; diff --git a/src/features/browser/api.ts b/src/features/browser/api.ts index 29a9f99b4..e45f4bb25 100644 --- a/src/features/browser/api.ts +++ b/src/features/browser/api.ts @@ -1,7 +1,18 @@ import type { ComponentType } from "react"; +/** One signed-in Builderlab lifetime; never passed to website JavaScript. */ +export type BuilderlabViewerSession = Readonly<{ + credential: string; + storage: string; + signal: AbortSignal; +}>; + export type BrowserViewProps = Readonly<{ url: string; + /** Bootstrap only the configured Builderlab tenant's app using its native viewer exchange. */ + builderlab?: BuilderlabViewerSession; + /** Which polled destinations may reopen after guest eviction. Native auth still validates the destination. */ + canResumeUrl?: ((url: string) => boolean) | undefined; /** Keeps the website's cookies and storage under this name between opens * and restarts (1-64 of a-z, 0-9, `-`, `_`, `.`), where the platform can hold * them apart from Buzz's own. Without it each open is a private session. */ diff --git a/src/features/browser/platform.test.ts b/src/features/browser/platform.test.ts index b7927565f..5de59808b 100644 --- a/src/features/browser/platform.test.ts +++ b/src/features/browser/platform.test.ts @@ -64,11 +64,21 @@ describe("createBrowserPlatform", () => { expect(sdk.invoke.mock.calls).toEqual([ [ "browser_attach", - { url: "https://example.com/", bounds, storage: null }, + { + url: "https://example.com/", + bounds, + storage: null, + builderlab: null, + }, ], [ "browser_attach", - { url: "https://example.com/", bounds, storage: "builderlab-apps" }, + { + url: "https://example.com/", + bounds, + storage: "builderlab-apps", + builderlab: null, + }, ], ["browser_set_bounds", { sessionId: "session-1", bounds, visible: true }], [ @@ -81,6 +91,48 @@ describe("createBrowserPlatform", () => { ]); }); + it("does not navigate an authenticated attach that finishes after sign-out", async () => { + let finish!: (value: string) => void; + sdk.invoke.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }) as never, + ); + const lifetime = new AbortController(); + const platform = createBrowserPlatform(); + const attach = platform.attach( + "https://counter--workspace.apps.example/second", + { x: 0, y: 0, width: 1, height: 1 }, + "account", + { credential: "private", storage: "account", signal: lifetime.signal }, + ); + const rejected = expect(attach).rejects.toThrow(); + lifetime.abort(); + finish("late-session"); + await rejected; + expect(sdk.invoke).toHaveBeenCalledTimes(2); + expect(sdk.invoke).toHaveBeenNthCalledWith(2, "browser_detach", { + sessionId: "late-session", + }); + }); + + it("navigates the original deep URL only after authenticated cookie installation", async () => { + sdk.invoke.mockResolvedValueOnce("viewer" as never); + const lifetime = new AbortController(); + const url = "https://counter--workspace.apps.example/second?q=1#part"; + await createBrowserPlatform().attach( + url, + { x: 0, y: 0, width: 1, height: 1 }, + "account", + { credential: "private", storage: "account", signal: lifetime.signal }, + ); + expect(sdk.invoke.mock.calls.at(-1)).toEqual([ + "browser_navigate", + { sessionId: "viewer", url }, + ]); + }); + it("rejects an invalid URL before native invocation", async () => { const platform = createBrowserPlatform(); await expect( diff --git a/src/features/browser/platform.ts b/src/features/browser/platform.ts index 6760f2fa1..cad96fffb 100644 --- a/src/features/browser/platform.ts +++ b/src/features/browser/platform.ts @@ -1,3 +1,4 @@ +import type { BuilderlabViewerSession } from "./api"; import { invoke, isTauri } from "@tauri-apps/api/core"; const MAX_URL_BYTES = 2048; const encoder = new TextEncoder(); @@ -46,7 +47,12 @@ export type BrowserAction = "back" | "forward" | "reload"; export interface BrowserPlatform { readonly available: boolean; - attach(url: string, bounds: BrowserBounds, storage?: string): Promise; + attach( + url: string, + bounds: BrowserBounds, + storage?: string, + builderlab?: BuilderlabViewerSession, + ): Promise; setBounds( sessionId: string, bounds: BrowserBounds, @@ -71,12 +77,35 @@ export function createBrowserPlatform(): BrowserPlatform { /Mac/i.test(navigator.platform); return { available, - attach: async (url, bounds, storage) => - invoke("browser_attach", { - url: normalizedUrl(url), + attach: async (url, bounds, storage, builderlab) => { + const target = normalizedUrl(url); + builderlab?.signal.throwIfAborted(); + // Authenticated attach installs the viewer cookie but leaves the guest blank. + // Check the account lifetime before the separate first navigation. + const sessionId = await invoke("browser_attach", { + url: target, bounds, storage: storage ?? null, - }), + builderlab: builderlab ? { credential: builderlab.credential } : null, + }); + if (builderlab) { + const close = () => { + void invoke("browser_detach", { sessionId }); + }; + builderlab.signal.addEventListener("abort", close, { once: true }); + try { + builderlab.signal.throwIfAborted(); + await invoke("browser_navigate", { sessionId, url: target }); + builderlab.signal.throwIfAborted(); + } catch (error) { + await invoke("browser_detach", { sessionId }); + throw error; + } finally { + builderlab.signal.removeEventListener("abort", close); + } + } + return sessionId; + }, setBounds: (sessionId, bounds, visible) => invoke("browser_set_bounds", { sessionId, bounds, visible }), navigate: async (sessionId, url) => diff --git a/src/features/browser/sessions.test.ts b/src/features/browser/sessions.test.ts index ff645b5b3..a9bf07f08 100644 --- a/src/features/browser/sessions.test.ts +++ b/src/features/browser/sessions.test.ts @@ -1,3 +1,4 @@ +import { setImmediate as nextTurn } from "node:timers/promises"; import { expect, it, vi } from "vitest"; import type { BrowserPlatform } from "./platform"; import { BrowserSessions } from "./sessions"; @@ -118,3 +119,166 @@ it("a parked guest is adopted only under its key", async () => { sessions.attach("https://a.example", bounds, () => true, "s", "a"), ).resolves.toBe("session-1"); }); + +it("sign-out closes live and parked account guests even while another attach is pending", async () => { + const browserPlatform = platform(); + const held = deferred(); + vi.mocked(browserPlatform.attach) + .mockResolvedValueOnce("live") + .mockResolvedValueOnce("parked") + .mockImplementationOnce(() => held.promise); + const sessions = new BrowserSessions(browserPlatform); + const lifetime = new AbortController(); + const viewer = { + credential: "private", + storage: "account-one", + signal: lifetime.signal, + }; + await sessions.attach( + "https://a.example", + bounds, + () => true, + viewer.storage, + "a", + viewer, + ); + await sessions.attach( + "https://b.example", + bounds, + () => true, + viewer.storage, + "b", + viewer, + ); + await sessions.detach("parked", "b"); + const pending = sessions.attach( + "https://c.example", + bounds, + () => true, + viewer.storage, + "c", + viewer, + ); + await vi.waitFor(() => + expect(browserPlatform.attach).toHaveBeenCalledTimes(3), + ); + lifetime.abort(); + expect(browserPlatform.detach).toHaveBeenCalledWith("live"); + expect(browserPlatform.detach).toHaveBeenCalledWith("parked"); + expect(sessions.owns("live")).toBe(false); + expect(sessions.owns("parked")).toBe(false); + held.resolve("pending"); + await expect(pending).resolves.toBeUndefined(); + expect(browserPlatform.detach).toHaveBeenCalledWith("pending"); + const other = new AbortController(); + vi.mocked(browserPlatform.attach).mockResolvedValueOnce("other-account"); + await sessions.attach( + "https://b.example", + bounds, + () => true, + "account-two", + "b", + { + credential: "other", + storage: "account-two", + signal: other.signal, + }, + ); + expect(sessions.owns("other-account")).toBe(true); + expect(browserPlatform.attach).toHaveBeenCalledTimes(4); + other.abort(); +}); + +it.each([undefined, "a"])( + "hides a closing guest before another authenticated attach finishes (park %s)", + async (park) => { + const browserPlatform = platform(); + const held = deferred(); + vi.mocked(browserPlatform.attach) + .mockResolvedValueOnce("a") + .mockImplementationOnce(() => held.promise); + const sessions = new BrowserSessions(browserPlatform); + await sessions.attach("https://a.example", bounds, () => true); + sessions.present("a", true); + const lifetime = new AbortController(); + const pending = sessions.attach( + "https://b.example", + bounds, + () => true, + "account", + "b", + { + credential: "private", + storage: "account", + signal: lifetime.signal, + }, + ); + await vi.waitFor(() => + expect(browserPlatform.attach).toHaveBeenCalledTimes(2), + ); + const detached = sessions.detach("a", park); + try { + expect(browserPlatform.setBounds).toHaveBeenCalledWith( + "a", + expect.anything(), + false, + ); + expect(browserPlatform.attach).toHaveBeenCalledTimes(2); + } finally { + held.resolve("b"); + await pending; + await detached; + await sessions.detach("a"); + lifetime.abort(); + } + }, +); + +it.each([undefined, "a"])( + "closes a guest whose hide failed (park %j) and reports the hide error", + async (park) => { + const browserPlatform = platform(); + const failure = new Error("hide failed"); + vi.mocked(browserPlatform.attach).mockResolvedValue("a"); + vi.mocked(browserPlatform.setBounds).mockRejectedValueOnce(failure); + const sessions = new BrowserSessions(browserPlatform); + await sessions.attach("https://a.example", bounds, () => true, "s", "a"); + await expect(sessions.detach("a", park)).rejects.toBe(failure); + expect(browserPlatform.detach).toHaveBeenCalledWith("a"); + expect(sessions.owns("a")).toBe(false); + await expect( + sessions.attach("https://a.example", bounds, () => true, "s", "a"), + ).resolves.toBe("a"); + expect(browserPlatform.attach).toHaveBeenCalledTimes(2); + }, +); + +it("does not re-adopt a parked guest before its previous hide completes", async () => { + const browserPlatform = platform(); + const hidden = deferred(); + vi.mocked(browserPlatform.attach).mockResolvedValue("a"); + vi.mocked(browserPlatform.setBounds).mockImplementationOnce( + () => hidden.promise, + ); + const sessions = new BrowserSessions(browserPlatform); + await sessions.attach("https://a.example", bounds, () => true, "s", "a"); + const detached = sessions.detach("a", "a"); + let adopted = false; + const reopened = sessions + .attach("https://a.example", bounds, () => true, "s", "a") + .then((id) => { + adopted = true; + return id; + }); + try { + // Submitted Promise jobs have run; the explicit native-hide gate remains held. + await nextTurn(); + expect(adopted).toBe(false); + expect(browserPlatform.attach).toHaveBeenCalledTimes(1); + } finally { + hidden.resolve(); + await detached; + await expect(reopened).resolves.toBe("a"); + await sessions.detach("a"); + } +}); diff --git a/src/features/browser/sessions.ts b/src/features/browser/sessions.ts index 0aaa60b2a..db6ef5ed9 100644 --- a/src/features/browser/sessions.ts +++ b/src/features/browser/sessions.ts @@ -1,3 +1,4 @@ +import type { BuilderlabViewerSession } from "./api"; import type { BrowserBounds, BrowserPlatform } from "./platform"; /** Native guests open at once; past this the least recently shown hidden one @@ -16,6 +17,7 @@ export class BrowserSessions { /** Live guests, least recently shown first. */ private live: string[] = []; private readonly shown = new Set(); + private readonly release = new Map void>(); private readonly parked = new Map< string, { sessionId: string; timer: ReturnType } @@ -33,9 +35,10 @@ export class BrowserSessions { storage?: string, /** Adopts the guest parked under this key instead of opening `url`. */ park?: string, + builderlab?: BuilderlabViewerSession, ): Promise { return this.enqueue(async () => { - if (!current()) return undefined; + if (!current() || builderlab?.signal.aborted) return undefined; const parked = park === undefined ? undefined : this.parked.get(park); if (park !== undefined && parked) { clearTimeout(parked.timer); @@ -43,9 +46,29 @@ export class BrowserSessions { return parked.sessionId; } while (this.live.length >= MAX_GUESTS) await this.close(this.victim()); - const sessionId = await this.platform.attach(url, bounds, storage); + const sessionId = await this.platform.attach( + url, + bounds, + storage, + builderlab, + ); this.live.push(sessionId); - if (current()) return sessionId; + if (current() && !builderlab?.signal.aborted) { + if (builderlab) { + // Revocation cannot wait behind another guest's network bootstrap. close removes + // ownership synchronously; queued lifecycle work then sees a closed session. + const invalidate = () => { + void this.close(sessionId); + }; + builderlab.signal.addEventListener("abort", invalidate, { + once: true, + }); + this.release.set(sessionId, () => + builderlab.signal.removeEventListener("abort", invalidate), + ); + } + return sessionId; + } await this.close(sessionId); return undefined; }); @@ -54,7 +77,19 @@ export class BrowserSessions { /** Closes the guest, or with `park` hides it for the next view that * attaches with that key. */ detach(sessionId: string, park?: string): Promise { - return this.enqueue(async () => { + // An unrelated attach can wait on network exchange; presentation must stop now. + const hidden = this.owns(sessionId) + ? this.platform.setBounds(sessionId, HIDDEN, false) + : Promise.resolve(); + this.shown.delete(sessionId); + const detached = this.enqueue(async () => { + // Re-adoption must not overtake the old view's in-flight hide. A guest + // that failed to hide still closes rather than staying live or parked. + try { + await hidden; + } catch { + return this.close(sessionId); + } if (park === undefined || !this.owns(sessionId)) return this.close(sessionId); this.shown.delete(sessionId); @@ -70,8 +105,8 @@ export class BrowserSessions { PARK_MS, ); this.parked.set(park, { sessionId, timer }); - await this.platform.setBounds(sessionId, HIDDEN, false); }); + return Promise.all([hidden, detached]).then(() => undefined); } /** The last title the page opened under `key` reported. */ @@ -112,6 +147,9 @@ export class BrowserSessions { } private close(sessionId: string): Promise { + this.release.get(sessionId)?.(); + this.release.delete(sessionId); + if (!this.owns(sessionId)) return Promise.resolve(); this.live = this.live.filter((id) => id !== sessionId); this.shown.delete(sessionId); for (const [key, entry] of this.parked) diff --git a/tests/browser/apps-panel.spec.mjs b/tests/browser/apps-panel.spec.mjs index adc9346db..803451b8c 100644 --- a/tests/browser/apps-panel.spec.mjs +++ b/tests/browser/apps-panel.spec.mjs @@ -35,6 +35,11 @@ test("Apps keeps its last native URL after Cordis rerenders and pool eviction", await expect .poll(() => page.evaluate(() => window.appsPanelCalls.attached.length)) .toBe(1); + await expect + .poll(() => + page.evaluate(() => window.appsPanelCalls.attached[0]?.authenticated), + ) + .toBe(true); await address().fill(current); await address().press("Enter"); // This observed address comes from a completed native status poll. diff --git a/tests/fixtures/apps-panel.tsx b/tests/fixtures/apps-panel.tsx index 27439a06b..6f8c34edf 100644 --- a/tests/fixtures/apps-panel.tsx +++ b/tests/fixtures/apps-panel.tsx @@ -16,7 +16,7 @@ import { ProfilePeek } from "../../src/features/profiles/ProfilePeek"; // The native adapter is substituted; Cordis, plugin registration, React view // identity and the four-guest pool are the production collaborators. const calls = { - attached: [] as { id: string; url: string }[], + attached: [] as { id: string; url: string; authenticated: boolean }[], detached: [] as string[], statuses: [] as { id: string; url: string }[], presentations: [] as { id: string; visible: boolean }[], @@ -26,9 +26,9 @@ Object.assign(window, { appsPanelCalls: calls }); const pages = new Map(); const platform: BrowserPlatform = { available: true, - async attach(url) { + async attach(url, _bounds, _storage, builderlab) { const id = `guest-${calls.attached.length + 1}`; - calls.attached.push({ id, url }); + calls.attached.push({ id, url, authenticated: Boolean(builderlab) }); pages.set(id, url); return id; }, @@ -57,11 +57,19 @@ ctx.provide("pluginStatus", { ctx.provide("agents", { addTool() {} }); new BrowserService(ctx, platform); const panels = new PanelsService(ctx); +const session = createOAuthSession(async () => ({ + value: "synthetic-fixture-credential", + account: { + subject: "fixture|viewer", + email: "viewer@example.test", + canUseRemoteAgents: false, + }, +})); +await session.signIn(); applyApps( ctx.extend({ pluginOwner: { id: "fixture.apps", revision: "one" } }) as never, - createOAuthSession(async () => { - throw new Error("Sign-in is unused in this fixture"); - }), + session, + true, ); const urls = [1, 2, 3, 4, 5].map( (id) => `https://app${id}--bpsites.apps.builderlabstaging.xyz/`, diff --git a/vite.config.ts b/vite.config.ts index 872901c8c..df74923da 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -88,6 +88,8 @@ export default defineConfig(async ({ command, mode }) => { "import.meta.env.VITE_BUZZ_BUILDERLAB_URL": JSON.stringify( env.BUZZ_BUILDERLAB_URL ?? "https://app.builderlab.xyz", ), + "import.meta.env.VITE_BUZZ_BUILDERLAB_APPS_DOMAIN": + JSON.stringify(env.BUZZ_BUILDERLAB_APPS_DOMAIN) ?? "undefined", "import.meta.env.VITE_BUZZ_OPEN_RELAY": JSON.stringify(openOrigin), "import.meta.env.VITE_BUZZ_DATABRICKS_HOST": JSON.stringify(databricksHost),