From 6baa5095f389e9faed16472a6634a2c2dc2dae51 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Fri, 23 Oct 2026 16:22:24 -0400 Subject: [PATCH 01/13] Use Settings sign-in for Builderlab app viewers Co-authored-by: Codex Signed-off-by: Nathan Thillairajah --- docs/browser.md | 9 + src-tauri/Cargo.toml | 2 +- src-tauri/src/browser/builderlab.rs | 291 ++++++++++++++++++ src-tauri/src/browser/mod.rs | 80 ++++- src-tauri/src/browser/platform.rs | 80 ++++- src/bundled/builderlab/README.md | 19 +- src/bundled/builderlab/apps/environment.ts | 33 +- src/bundled/builderlab/apps/index.ts | 43 ++- src/bundled/builderlab/apps/viewer.test.tsx | 128 ++++++++ src/bundled/builderlab/index.tsx | 24 +- src/bundled/builderlab/login/Login.tsx | 8 +- src/bundled/builderlab/oauth/session.test.ts | 34 ++ src/bundled/builderlab/oauth/session.ts | 29 +- src/features/browser/BrowserHostView.test.tsx | 2 + src/features/browser/BrowserHostView.tsx | 9 +- src/features/browser/api.ts | 9 + src/features/browser/platform.test.ts | 14 +- src/features/browser/platform.ts | 11 +- src/features/browser/sessions.test.ts | 69 +++++ src/features/browser/sessions.ts | 32 +- 20 files changed, 879 insertions(+), 47 deletions(-) create mode 100644 src-tauri/src/browser/builderlab.rs create mode 100644 src/bundled/builderlab/apps/viewer.test.tsx diff --git a/docs/browser.md b/docs/browser.md index 1d74febf4..b72343bf4 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -37,6 +37,15 @@ 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 a short-lived, +per-open HttpOnly proof cookie on that tenant's exact trusted login alias. No +credential or generic cookie/header capability is exposed to the guest. Each +Settings 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/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8a3a52bf4..0b6336d4e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -94,7 +94,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..85f7d6bf0 --- /dev/null +++ b/src-tauri/src/browser/builderlab.rs @@ -0,0 +1,291 @@ +//! Native-only bootstrap. Neither the API credential nor verifier is delivered to website code. +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use url::Url; +use zeroize::Zeroizing; + +const HANDOFF_PATH: &str = "/api/goose/v1/auth/hosted-site/handoff"; +const BINDING_COOKIE: &str = "__Host-bbidentity_native_hosted_site_handoff"; + +#[derive(Deserialize)] +pub struct ViewerAuth { + credential: String, +} + +pub(super) struct Bootstrap { + pub url: String, + pub root: String, + pub cookie: Zeroizing, + pub login_origin: String, +} + +fn app_domain(origin: &str) -> Option<&'static str> { + match origin { + "https://app.builderlab.xyz" => Some("apps.builderlab.xyz"), + "https://app.builderlabstaging.xyz" => Some("apps.builderlabstaging.xyz"), + "https://block.builderlab.xyz" => Some("apps.block.builderlab.xyz"), + _ => None, + } +} + +fn app_root(origin: &str, target: &str) -> Result { + let domain = app_domain(origin).ok_or("Builderlab Apps sign-in is not configured")?; + 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 validate_handoff(value: &str, root: &str, domain: &str, hash: &str) -> Result { + let url = Url::parse(value).map_err(|_| "Invalid Builderlab sign-in response")?; + let pairs: Vec<_> = url.query_pairs().collect(); + let expected = [ + ("returnTo", root), + ("hosted_handoff_binding", hash), + ("native_handoff", "true"), + ]; + let code = pairs + .iter() + .find(|(key, _)| key == "code") + .map(|(_, value)| value.as_ref()); + if value.len() > 2048 + || url.scheme() != "https" + || url.host_str() != Some(format!("login.{domain}").as_str()) + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != HANDOFF_PATH + || url.fragment().is_some() + || pairs.len() != 4 + || !expected + .iter() + .all(|(key, value)| pairs.iter().filter(|(k, v)| k == key && v == value).count() == 1) + || !code.is_some_and(|code| { + code.len() == 43 + && code + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"_-".contains(&c)) + }) + { + return Err("Invalid Builderlab sign-in response".into()); + } + Ok(url) +} + +/// Follow the alias through an app root redirect without replacing cross-origin login/consent pages. +pub(super) fn resume_app_target( + pending: &mut Option<(String, String)>, + landed: &str, +) -> Option { + let (root, _) = pending.as_ref()?; + let root = Url::parse(root).ok()?; + let landed = Url::parse(landed).ok()?; + if root.origin() != landed.origin() { + return None; + } + pending.take().map(|(_, target)| target) +} + +pub(super) async fn prepare(target: &str, auth: ViewerAuth) -> Result { + let origin = buzzodz_plugins::BUILDERLAB_ORIGIN; + let root = app_root(origin, target)?; + let credential = Zeroizing::new(auth.credential); + 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 mut random = [0; 32]; + getrandom::fill(&mut random).map_err(|_| "Builderlab browser proof is unavailable")?; + let verifier = Zeroizing::new(URL_SAFE_NO_PAD.encode(random)); + let hash = URL_SAFE_NO_PAD.encode(Sha256::digest(format!( + "bbidentity-builderlab-hosted-site-handoff:{}", + &*verifier + ))); + 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, "browser_binding_verifier": &*verifier }) + .to_string(), + ); + let mut response = client + .post(format!( + "{origin}/api/goose/v1/auth/hosted-site/native-handoff" + )) + .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 !response.status().is_success() { + return Err(match response.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", + 404 => "Builderlab viewer sign-in is not enabled yet", + _ => "Builderlab viewer sign-in is unavailable. Try reopening this app", + } + .into()); + } + 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 { + handoff_url: String, + expires_at: String, + } + let reply: Reply = + serde_json::from_slice(&bytes).map_err(|_| "Invalid Builderlab sign-in response")?; + let url = validate_handoff( + &reply.handoff_url, + &root, + app_domain(origin).unwrap(), + &hash, + )?; + let expires = chrono::DateTime::parse_from_rfc3339(&reply.expires_at) + .map_err(|_| "Invalid Builderlab sign-in expiry")? + .timestamp(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| "Invalid system clock")? + .as_secs() as i64; + let lifetime = expires - now; + if !(1..=120).contains(&lifetime) { + return Err("Builderlab sign-in expired. Reopen this app".into()); + } + Ok(Bootstrap { + login_origin: url.origin().ascii_serialization(), url: url.into(), root, + cookie: Zeroizing::new(format!("{BINDING_COOKIE}_{hash}={}; Path=/; Max-Age={lifetime}; Secure; HttpOnly; SameSite=Lax", &*verifier)), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn app_root_redirect_restores_deep_target_only_after_same_origin_landing() { + let target = "https://counter.apps.builderlab.xyz/report/42?q=1#part"; + let mut pending = Some(("https://counter.apps.builderlab.xyz/".into(), target.into())); + assert_eq!( + resume_app_target(&mut pending, "https://login.apps.builderlab.xyz/consent"), + None + ); + assert!(pending.is_some()); + // A root 302 to /dashboard finishes there, rather than at the exact root. + assert_eq!( + resume_app_target( + &mut pending, + "https://counter.apps.builderlab.xyz/dashboard" + ), + Some(target.into()) + ); + assert!(pending.is_none()); + assert_eq!(resume_app_target(&mut pending, target), None); + } + + #[test] + fn credentials_are_only_used_for_same_tenant_exact_app_hosts() { + let origin = "https://app.builderlab.xyz"; + assert_eq!( + app_root( + origin, + "https://counter--bpsites.apps.builderlab.xyz/second?q=1#part" + ) + .unwrap(), + "https://counter--bpsites.apps.builderlab.xyz/" + ); + for target in [ + "https://counter.apps.block.builderlab.xyz/", + "https://apps.builderlab.xyz/", + "https://login.apps.builderlab.xyz/", + "https://health.apps.builderlab.xyz/", + "https://nested.app.apps.builderlab.xyz/", + "https://app.apps.builderlab.xyz.evil.test/", + "http://counter.apps.builderlab.xyz/", + "https://user@counter.apps.builderlab.xyz/", + "https://counter.apps.builderlab.xyz:8443/", + ] { + assert!(app_root(origin, target).is_err(), "{target}"); + } + assert!(app_root("https://evil.test", "https://counter.apps.builderlab.xyz/").is_err()); + } + + #[test] + fn handoff_requires_exact_alias_root_native_binding_and_one_opaque_code() { + let root = "https://counter.apps.builderlab.xyz/"; + let hash = "H".repeat(43); + let mut good = + Url::parse("https://login.apps.builderlab.xyz/api/goose/v1/auth/hosted-site/handoff") + .unwrap(); + good.query_pairs_mut() + .append_pair("code", &"C".repeat(43)) + .append_pair("returnTo", root) + .append_pair("hosted_handoff_binding", &hash) + .append_pair("native_handoff", "true"); + assert!(validate_handoff(good.as_str(), root, "apps.builderlab.xyz", &hash).is_ok()); + for (key, value) in [ + ("returnTo", "https://evil.test/"), + ("hosted_handoff_binding", "wrong"), + ("native_handoff", "false"), + ("code", "short"), + ] { + let mut bad = good.clone(); + let pairs: Vec<_> = bad + .query_pairs() + .filter(|(k, _)| k != key) + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + bad.set_query(None); + bad.query_pairs_mut() + .extend_pairs(pairs) + .append_pair(key, value); + assert!(validate_handoff(bad.as_str(), root, "apps.builderlab.xyz", &hash).is_err()); + } + let mut duplicate = good.clone(); + duplicate + .query_pairs_mut() + .append_pair("code", &"C".repeat(43)); + assert!(validate_handoff(duplicate.as_str(), root, "apps.builderlab.xyz", &hash).is_err()); + for host in [ + "counter.apps.builderlab.xyz", + "login.apps.block.builderlab.xyz", + "login.apps.builderlab.xyz.evil.test", + ] { + let mut bad = good.clone(); + bad.set_host(Some(host)).unwrap(); + assert!(validate_handoff(bad.as_str(), root, "apps.builderlab.xyz", &hash).is_err()); + } + let mut bad = good.clone(); + bad.set_fragment(Some("fragment")); + assert!(validate_handoff(bad.as_str(), root, "apps.builderlab.xyz", &hash).is_err()); + } +} diff --git a/src-tauri/src/browser/mod.rs b/src-tauri/src/browser/mod.rs index 7fed2a813..6370016c5 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,7 +219,9 @@ fn build_contents( target: &str, bounds: BrowserBounds, storage: Option<&str>, + bootstrap: Option<&builderlab::Bootstrap>, ) -> Result { + let instance = uuid::Uuid::new_v4().to_string(); let policy = NavigationPolicy::new(app.config().build.dev_url.clone()); let target = policy.validate(target)?; bounds.validate()?; @@ -233,6 +236,12 @@ fn build_contents( let download_snapshot = snapshot.clone(); // No URL is loaded until native permission handlers replace Wry's defaults. // In particular, this builder receives no IPC handler or host initialization scripts. + let resume_target = bootstrap + .filter(|value| value.root != target.as_str()) + .map(|value| (value.root.clone(), target.to_string())); + let resume_target = Rc::new(RefCell::new(resume_target)); + let resume_instance = instance.clone(); + let resume_app = app.clone(); let builder = platform::with_storage(WebViewBuilder::new(), storage) .with_visible(false) .with_clipboard(false) @@ -249,6 +258,19 @@ fn build_contents( title_snapshot.borrow_mut().title = title; }) .with_on_page_load_handler(move |event, url| { + // The alias sets the restricted cookie before redirecting to the validated root. + // Restore an app deep link after that hop without widening the server's redirect contract. + let resume = if matches!(event, PageLoadEvent::Finished) { + builderlab::resume_app_target(&mut resume_target.borrow_mut(), &url) + } else { + None + }; + if let Some(target) = resume { + let instance = resume_instance.clone(); + let _ = resume_app.run_on_main_thread(move || { + let _ = with_browser(&instance, |browser| browser.navigate(&target)); + }); + } let mut snapshot = load_snapshot.borrow_mut(); snapshot.url = url; snapshot.loading = matches!(event, PageLoadEvent::Started); @@ -262,7 +284,7 @@ fn build_contents( let guest = platform::build_guest(builder, &host.window())?; let permissions = platform::deny_permissions(&guest)?; let browser = BrowserSession { - instance: uuid::Uuid::new_v4().to_string(), + instance, host, guest, _permissions: permissions, @@ -270,7 +292,9 @@ fn build_contents( policy, }; platform::resize_guest(&browser.guest, &browser.host, bounds)?; - browser.navigate(target.as_str())?; + if bootstrap.is_none() { + browser.navigate(target.as_str())?; + } Ok(browser) } @@ -280,12 +304,28 @@ pub async fn browser_attach( url: String, bounds: BrowserBounds, storage: Option, + builderlab: Option, ) -> Result { let generation = DOCUMENT_GENERATION.load(Ordering::SeqCst); + let bootstrap = match builderlab { + Some(auth) => Some(builderlab::prepare(&url, auth).await?), + None => None, + }; let handle = app.clone(); - on_main_thread(&app, move || { + let (session_id, pending, navigation) = 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(), + bootstrap.as_ref(), + )?; + let pending = bootstrap + .as_ref() + .map(|value| platform::install_builderlab_binding(&browser.guest, value)) + .transpose()?; + let navigation = bootstrap.map(|value| value.url); let session_id = browser.instance.clone(); BROWSERS.with(|browsers| { let mut browsers = browsers.borrow_mut(); @@ -293,10 +333,38 @@ pub async fn browser_attach( let excess = browsers.len().saturating_sub(MAX_GUESTS); browsers.drain(..excess); }); - Ok(session_id) + Ok((session_id, pending, navigation)) }) }) - .await + .await?; + if let (Some(mut pending), Some(navigation)) = (pending, navigation) { + 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 browser proof could not be installed".into()); + } + let result = with_current_document(&DOCUMENT_GENERATION, generation, || { + with_browser(&id, |browser| browser.navigate(&navigation)) + }); + 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..1feab0164 100644 --- a/src-tauri/src/browser/platform.rs +++ b/src-tauri/src/browser/platform.rs @@ -71,6 +71,74 @@ mod macos { >= 14 } + fn builderlab_binding_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.login_origin)) + .ok_or("Invalid Builderlab login 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 browser proof is unavailable")?; + let host = url::Url::parse(&bootstrap.login_origin) + .map_err(|_| "Invalid Builderlab login origin")?; + if cookies.count() != 1 + || !cookie.isHTTPOnly() + || !cookie.isSecure() + || cookie.expiresDate().is_none() + || cookie.domain().to_string() != host.host_str().unwrap_or_default() + || cookie.path().to_string() != "/" + { + return Err("Builderlab browser proof is unavailable".into()); + } + Ok(cookie) + } + + /// Installs only the native-generated proof; never reads cookies or accepts caller cookie fields. + pub fn install_builderlab_binding( + guest: &WebView, + bootstrap: &super::super::builderlab::Bootstrap, + ) -> Result, String> { + let cookie = builderlab_binding_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) + } + + #[cfg(test)] + mod binding_tests { + use super::*; + #[test] + fn generated_proof_is_host_only_http_only_secure_and_expires() { + let value = super::super::super::builderlab::Bootstrap { + url: String::new(), root: String::new(), login_origin: "https://login.apps.builderlab.xyz".into(), + cookie: zeroize::Zeroizing::new("__Host-bbidentity_native_hosted_site_handoff_hash=proof; Path=/; Max-Age=60; Secure; HttpOnly; SameSite=Lax".into()), + }; + let cookie = builderlab_binding_cookie(&value).unwrap(); + assert_eq!(cookie.domain().to_string(), "login.apps.builderlab.xyz"); + assert_eq!(cookie.path().to_string(), "/"); + assert!(cookie.isHTTPOnly()); + assert!(cookie.isSecure()); + assert!(!cookie.isSessionOnly()); + assert!(cookie.expiresDate().is_some()); + } + } + pub fn loading(guest: &WebView) -> bool { unsafe { guest.webview().isLoading() } } @@ -192,7 +260,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_binding, loading, +}; #[cfg(target_os = "macos")] pub(super) type Permissions = objc2::rc::Retained; @@ -332,3 +402,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_binding( + _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 d5e65f1c8..ff18fe358 100644 --- a/src/bundled/builderlab/README.md +++ b/src/bundled/builderlab/README.md @@ -34,11 +34,20 @@ 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 two-minute, one-use +handoff, installs a host-only HttpOnly browser proof on the exact trusted login +alias, and follows that alias to the registered app. The guest receives only the +restricted hosted-site cookie. 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 revokes 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 handoff 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. ## Remote agents diff --git a/src/bundled/builderlab/apps/environment.ts b/src/bundled/builderlab/apps/environment.ts index 67812ac47..c7da29417 100644 --- a/src/bundled/builderlab/apps/environment.ts +++ b/src/bundled/builderlab/apps/environment.ts @@ -22,7 +22,7 @@ export function controlPlane( /** Hosts that serve deployed apps, a subdomain each, per tenant. */ const APP_DOMAINS = [ - "apps.public.builderlab.xyz", + "apps.builderlab.xyz", "apps.builderlabstaging.xyz", "apps.block.builderlab.xyz", "apps.test.blockstaging.build", @@ -53,3 +53,34 @@ export function isAppUrl(target: string) { APP_DOMAINS.some((domain) => host.endsWith(`.${domain}`)) ); } + +/** 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 ?? "", +) { + const domains: Readonly> = { + "https://app.builderlab.xyz": "apps.builderlab.xyz", + "https://app.builderlabstaging.xyz": "apps.builderlabstaging.xyz", + "https://block.builderlab.xyz": "apps.block.builderlab.xyz", + }; + try { + const url = new URL(target); + const domain = domains[new URL(origin).origin]; + const label = domain && url.hostname.slice(0, -(domain.length + 1)); + return ( + isAppUrl(target) && + 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.ts b/src/bundled/builderlab/apps/index.ts index db7f8dfd3..3e8f5057f 100644 --- a/src/bundled/builderlab/apps/index.ts +++ b/src/bundled/builderlab/apps/index.ts @@ -1,12 +1,15 @@ // 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 } from "./environment"; + +import { EmptyState } from "../../../shared/design-system/ui/EmptyState"; +import { Login } from "../login/Login"; type Ctx = Parameters[0]; /** How long `apps_deploy` waits for the new version to serve. */ @@ -37,20 +40,38 @@ It returns the version/deployment IDs once accepted, even if readiness checking export function applyApps(ctx: Ctx, session: OAuthSession) { // 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, + ); + if (!isViewerTarget(target)) + return createElement(EmptyState, { + icon: null, + title: "Open this app with its Builderlab environment", + description: + "This app uses a different Builderlab environment than this Buzz build.", + }); + if (state.status !== "signed-in") + return createElement(Login, { + session, + cancelOnUnmount: false, + available: true, + active: () => true, + }); + return createElement(View, { + url: target, + builderlab: session.viewerSession(), + 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..4001bd018 --- /dev/null +++ b/src/bundled/builderlab/apps/viewer.test.tsx @@ -0,0 +1,128 @@ +// @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--bpsites.apps.builderlab.xyz/report/42"; +afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); +}); + +function setup() { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://app.builderlab.xyz"); + 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); + 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("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 never receives the Settings credential", async () => { + const { session, viewed, panel } = setup(); + await session.signIn(); + render( + createElement(panel.component, { + target: "https://counter.apps.block.builderlab.xyz/", + } as PanelProps), + ); + expect( + screen.getByText("Open this app with its Builderlab environment"), + ).toBeInTheDocument(); + expect(viewed).not.toHaveBeenCalled(); + session.dispose(); +}); diff --git a/src/bundled/builderlab/index.tsx b/src/bundled/builderlab/index.tsx index 4da7a2711..8aa0d928b 100644 --- a/src/bundled/builderlab/index.tsx +++ b/src/bundled/builderlab/index.tsx @@ -34,11 +34,25 @@ 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 backupSession = () => { 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 d3cee6810..69c30f757 100644 --- a/src/features/browser/BrowserHostView.test.tsx +++ b/src/features/browser/BrowserHostView.test.tsx @@ -419,6 +419,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( @@ -452,6 +453,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(); diff --git a/src/features/browser/BrowserHostView.tsx b/src/features/browser/BrowserHostView.tsx index 096da78e2..63b9d3559 100644 --- a/src/features/browser/BrowserHostView.tsx +++ b/src/features/browser/BrowserHostView.tsx @@ -83,7 +83,8 @@ function hostOverlayOpen(viewport: BrowserBounds): boolean { export function BrowserHostView({ url, - storage, + storage: requestedStorage, + builderlab, label, platform, sessions, @@ -93,6 +94,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); @@ -227,7 +229,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; @@ -239,6 +241,7 @@ export function BrowserHostView({ () => !disposed, storage, park, + builderlab, ) .then((sessionId) => { if (!sessionId || disposed) return; @@ -337,7 +340,7 @@ 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, 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..4cd79c67f 100644 --- a/src/features/browser/api.ts +++ b/src/features/browser/api.ts @@ -1,7 +1,16 @@ 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 through its trusted login origin. */ + builderlab?: BuilderlabViewerSession; /** 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..465402549 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 }], [ diff --git a/src/features/browser/platform.ts b/src/features/browser/platform.ts index 6760f2fa1..86026f1be 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,11 +77,12 @@ export function createBrowserPlatform(): BrowserPlatform { /Mac/i.test(navigator.platform); return { available, - attach: async (url, bounds, storage) => + attach: async (url, bounds, storage, builderlab) => invoke("browser_attach", { url: normalizedUrl(url), bounds, storage: storage ?? null, + builderlab: builderlab ? { credential: builderlab.credential } : null, }), setBounds: (sessionId, bounds, visible) => invoke("browser_set_bounds", { sessionId, bounds, visible }), diff --git a/src/features/browser/sessions.test.ts b/src/features/browser/sessions.test.ts index ff645b5b3..4f45dd0da 100644 --- a/src/features/browser/sessions.test.ts +++ b/src/features/browser/sessions.test.ts @@ -118,3 +118,72 @@ 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(); +}); diff --git a/src/features/browser/sessions.ts b/src/features/browser/sessions.ts index 0aaa60b2a..0581a70df 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; }); @@ -112,6 +135,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) From dc71db601f2f6c9fa3845e070f94f0504b9ef5db Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Fri, 23 Oct 2026 16:25:55 -0400 Subject: [PATCH 02/13] Bound native browser proof expiry to its handoff ticket Co-authored-by: Codex Signed-off-by: Nathan Thillairajah --- src-tauri/src/browser/builderlab.rs | 12 ++++++--- src-tauri/src/browser/platform.rs | 40 +++++++++++++++-------------- src/bundled/builderlab/README.md | 2 +- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/browser/builderlab.rs b/src-tauri/src/browser/builderlab.rs index 85f7d6bf0..77d1cc7be 100644 --- a/src-tauri/src/browser/builderlab.rs +++ b/src-tauri/src/browser/builderlab.rs @@ -170,9 +170,9 @@ pub(super) async fn prepare(target: &str, auth: ViewerAuth) -> Result Result bool { unsafe { guest.webview().isLoading() } } @@ -177,6 +158,27 @@ mod macos { } Ok(()) } + #[cfg(test)] + mod binding_tests { + use super::*; + #[test] + fn generated_proof_is_host_only_http_only_secure_and_expires() { + let value = super::super::super::builderlab::Bootstrap { + url: String::new(), root: String::new(), login_origin: "https://login.apps.builderlab.xyz".into(), + cookie: zeroize::Zeroizing::new("__Host-bbidentity_native_hosted_site_handoff_hash=proof; Path=/; Expires=Tue, 19 Jan 2038 03:14:07 GMT; Secure; HttpOnly; SameSite=Lax".into()), + }; + let cookie = builderlab_binding_cookie(&value).unwrap(); + assert_eq!(cookie.domain().to_string(), "login.apps.builderlab.xyz"); + assert_eq!(cookie.path().to_string(), "/"); + assert!(cookie.isHTTPOnly()); + assert!(cookie.isSecure()); + assert!(!cookie.isSessionOnly()); + assert_eq!( + cookie.expiresDate().unwrap().timeIntervalSince1970(), + 2147483647.0 + ); + } + } } #[cfg(target_os = "macos")] diff --git a/src/bundled/builderlab/README.md b/src/bundled/builderlab/README.md index ff18fe358..48936c715 100644 --- a/src/bundled/builderlab/README.md +++ b/src/bundled/builderlab/README.md @@ -42,7 +42,7 @@ restricted hosted-site cookie. App authorization and consent still run at ingres 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 revokes the server CLI parent. +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 handoff feature must be enabled after its schema and auth From 11bad6871223193e0388fccdc7e5a731ae27a9fa Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Fri, 23 Oct 2026 16:29:09 -0400 Subject: [PATCH 03/13] Verify ticket-length browser proof expiry on macOS Co-authored-by: Codex Signed-off-by: Nathan Thillairajah --- src-tauri/src/browser/builderlab.rs | 2 +- src-tauri/src/browser/platform.rs | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/browser/builderlab.rs b/src-tauri/src/browser/builderlab.rs index 77d1cc7be..81372846d 100644 --- a/src-tauri/src/browser/builderlab.rs +++ b/src-tauri/src/browser/builderlab.rs @@ -115,7 +115,7 @@ pub(super) async fn prepare(target: &str, auth: ViewerAuth) -> Result::from_timestamp(expiry, 0).unwrap(); + let date = date.format("%a, %d %b %Y %H:%M:%S GMT"); let value = super::super::super::builderlab::Bootstrap { url: String::new(), root: String::new(), login_origin: "https://login.apps.builderlab.xyz".into(), - cookie: zeroize::Zeroizing::new("__Host-bbidentity_native_hosted_site_handoff_hash=proof; Path=/; Expires=Tue, 19 Jan 2038 03:14:07 GMT; Secure; HttpOnly; SameSite=Lax".into()), + cookie: zeroize::Zeroizing::new(format!("__Host-bbidentity_native_hosted_site_handoff_hash=proof; Path=/; Expires={date}; Secure; HttpOnly; SameSite=Lax")), }; let cookie = builderlab_binding_cookie(&value).unwrap(); assert_eq!(cookie.domain().to_string(), "login.apps.builderlab.xyz"); @@ -175,7 +184,7 @@ mod macos { assert!(!cookie.isSessionOnly()); assert_eq!( cookie.expiresDate().unwrap().timeIntervalSince1970(), - 2147483647.0 + expiry as f64 ); } } From 50e7c08729dcc4b24618e762672b208c4645cebe Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Fri, 23 Oct 2026 16:32:26 -0400 Subject: [PATCH 04/13] Resume evicted app viewers without replaying login tickets Co-authored-by: Codex Signed-off-by: Nathan Thillairajah --- src/features/browser/BrowserHostView.test.tsx | 44 +++++++++++++++++++ src/features/browser/BrowserHostView.tsx | 7 ++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/features/browser/BrowserHostView.test.tsx b/src/features/browser/BrowserHostView.test.tsx index 69c30f757..63089bc03 100644 --- a/src/features/browser/BrowserHostView.test.tsx +++ b/src/features/browser/BrowserHostView.test.tsx @@ -801,3 +801,47 @@ it("ignores status from a replaced session", async () => { await waitFor(() => expect(screen.getByTitle("New page")).toBeVisible()); expect(screen.queryByText("Old page")).not.toBeInTheDocument(); }); + +it("restarts a lost Builderlab guest at the app URL instead of replaying its login ticket", async () => { + 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( + , + ); + // 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(); +}); diff --git a/src/features/browser/BrowserHostView.tsx b/src/features/browser/BrowserHostView.tsx index 63b9d3559..fb78b3f94 100644 --- a/src/features/browser/BrowserHostView.tsx +++ b/src/features/browser/BrowserHostView.tsx @@ -210,7 +210,12 @@ export function BrowserHostView({ try { const next = await platform.status(sessionId); if (!disposed && session.current === sessionId) { - if (next.url) resumeUrl.current = next.url; + // A lost auth/consent page must bootstrap again from the app, never replay its one-use ticket. + if ( + next.url && + (!builderlab || new URL(next.url).origin === new URL(url).origin) + ) + resumeUrl.current = next.url; sessions.rememberTitle(titleKey, next.title); setStatus(next); } From b9ddb68887a9650ffce52cbed8100e518454b32a Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Fri, 23 Oct 2026 16:39:21 -0400 Subject: [PATCH 05/13] test(apps): authenticate the embedded viewer fixture Co-authored-by: Codex Signed-off-by: Nathan Thillairajah --- tests/browser/apps-panel.spec.mjs | 7 +++++++ tests/browser/source-fixture.mjs | 10 ++++++++-- tests/fixtures/apps-panel.tsx | 19 +++++++++++++------ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/tests/browser/apps-panel.spec.mjs b/tests/browser/apps-panel.spec.mjs index 6fa63fd55..5383cf305 100644 --- a/tests/browser/apps-panel.spec.mjs +++ b/tests/browser/apps-panel.spec.mjs @@ -1,5 +1,7 @@ import { test, expect } from "./source-fixture.mjs"; +test.use({ builderlabOrigin: "https://app.builderlabstaging.xyz" }); + // Browser integration: the real Cordis component proxy must not remount the // registered Apps panel as sibling tabs change. Native guest I/O is substituted. test("Apps keeps its last native URL after Cordis rerenders and pool eviction", async ({ @@ -14,6 +16,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/browser/source-fixture.mjs b/tests/browser/source-fixture.mjs index 6732c47dd..be6ce69d0 100644 --- a/tests/browser/source-fixture.mjs +++ b/tests/browser/source-fixture.mjs @@ -6,13 +6,19 @@ import { createServer } from "./vite-server.mjs"; // Only stateless source serving is shared. Playwright still owns a fresh context // per test; fixtures with mutable server middleware must keep their own server. export const test = base.extend({ + builderlabOrigin: ["", { scope: "worker", option: true }], sourceOrigin: [ - // biome-ignore lint/correctness/noEmptyPattern: Playwright requires a destructured fixture argument. - async ({}, use) => { + async ({ builderlabOrigin }, use) => { const server = await createServer({ root: fileURLToPath(new URL("../../", import.meta.url)), configFile: false, envFile: false, + define: builderlabOrigin + ? { + "import.meta.env.VITE_BUZZ_BUILDERLAB_URL": + JSON.stringify(builderlabOrigin), + } + : {}, plugins: [react()], logLevel: "error", server: { host: "127.0.0.1", port: 0, strictPort: true }, diff --git a/tests/fixtures/apps-panel.tsx b/tests/fixtures/apps-panel.tsx index 8d10badc0..937f2e7a3 100644 --- a/tests/fixtures/apps-panel.tsx +++ b/tests/fixtures/apps-panel.tsx @@ -11,7 +11,7 @@ import { PanelsService } from "../../src/features/panels/service"; // 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 }[], }; @@ -19,9 +19,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; }, @@ -48,11 +48,18 @@ 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, ); const urls = [1, 2, 3, 4, 5].map( (id) => `https://app${id}--bpsites.apps.builderlabstaging.xyz/`, From 6a79d38fd3991a8a1d834007f54c9a62a5764846 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Mon, 26 Oct 2026 10:50:44 -0400 Subject: [PATCH 06/13] Keep attach cancellation assertion compatible with typed mock Co-authored-by: Codex Signed-off-by: Nathan Thillairajah --- src/features/browser/platform.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/browser/platform.test.ts b/src/features/browser/platform.test.ts index 37ae2df03..ba43ded8e 100644 --- a/src/features/browser/platform.test.ts +++ b/src/features/browser/platform.test.ts @@ -111,10 +111,10 @@ describe("createBrowserPlatform", () => { lifetime.abort(); finish("late-session"); await rejected; - expect(sdk.invoke.mock.calls.map(([command]) => command)).toEqual([ - "browser_attach", - "browser_detach", - ]); + 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 () => { From b06251497a661b7eea8200a2de4e8b02f6dd9807 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Mon, 26 Oct 2026 15:48:34 -0400 Subject: [PATCH 07/13] Preserve browser login fallback and configure native viewer domains Co-authored-by: Codex Signed-off-by: Nathan Thillairajah --- .env.example | 4 + crates/plugin-manager/build.rs | 99 +++++++++++++++- crates/plugin-manager/src/lib.rs | 2 + docs/browser.md | 6 +- docs/configuration.md | 10 ++ src-tauri/src/browser/builderlab.rs | 107 ++++++++++-------- src-tauri/src/browser/mod.rs | 16 +-- src-tauri/src/browser/platform.rs | 8 +- .../builderlab/apps/environment.test.ts | 38 +++++++ src/bundled/builderlab/apps/environment.ts | 35 ++++-- src/bundled/builderlab/apps/index.ts | 10 +- src/bundled/builderlab/apps/viewer.test.tsx | 23 +++- src/features/browser/platform.test.ts | 4 +- tests/browser/apps-panel.spec.mjs | 2 - tests/browser/source-fixture.mjs | 10 +- vite.config.ts | 2 + 16 files changed, 284 insertions(+), 92 deletions(-) create mode 100644 src/bundled/builderlab/apps/environment.test.ts diff --git a/.env.example b/.env.example index 3905dc2c8..a044bc560 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,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 73647367d..23f84a652 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 local_url_and_process_overrides_produce_an_exact_origin() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 392213890..44bce5001 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -214,6 +214,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 94ce70419..40ad15443 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -43,9 +43,11 @@ 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. -The guest stays blank until installation finishes and its account lifetime is checked. No +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 -Settings sign-in selects separate storage; aborting that lifetime closes its live +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. diff --git a/docs/configuration.md b/docs/configuration.md index 9ad106220..605514110 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,6 +9,7 @@ not in source. 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 exact HTTPS host grant; 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_RELAY_URL` | Live development broker's default community, not an agent relay override or a packaged default. | | `BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY` | Presence-only alias for fresh-viewer community selection in live development only. Saved viewer choice wins. | @@ -72,6 +73,15 @@ only the configured URL's exact origin. Never put credentials in this input. 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/src/browser/builderlab.rs b/src-tauri/src/browser/builderlab.rs index fc2b5db8c..d14abef3c 100644 --- a/src-tauri/src/browser/builderlab.rs +++ b/src-tauri/src/browser/builderlab.rs @@ -20,17 +20,10 @@ pub(super) struct Bootstrap { pub cookie_domain: String, } -fn app_domain(origin: &str) -> Option<&'static str> { - match origin { - "https://app.builderlab.xyz" => Some("apps.builderlab.xyz"), - "https://app.builderlabstaging.xyz" => Some("apps.builderlabstaging.xyz"), - "https://block.builderlab.xyz" => Some("apps.block.builderlab.xyz"), - _ => None, +fn app_root(domain: &str, target: &str) -> Result { + if domain.is_empty() { + return Err("Builderlab Apps sign-in is not configured".into()); } -} - -fn app_root(origin: &str, target: &str) -> Result { - let domain = app_domain(origin).ok_or("Builderlab Apps sign-in is not configured")?; let url = Url::parse(target).map_err(|_| "Invalid Builderlab app URL")?; let label = url .host_str() @@ -74,10 +67,30 @@ fn cookie( ))) } -pub(super) async fn prepare(target: &str, auth: ViewerAuth) -> Result { +/// 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 root = app_root(origin, target)?; + 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() @@ -102,14 +115,8 @@ pub(super) async fn prepare(target: &str, auth: ViewerAuth) -> Result "Sign out and sign in to Builderlab again in Settings, then reopen this app", - 403 => "Your Builderlab account cannot open this app", - 404 => "Builderlab viewer sign-in is not enabled yet", - _ => "Builderlab viewer sign-in is unavailable. Try reopening this app", - } - .into()); + if !exchange_available(response.status())? { + return Ok(None); } let mut bytes = Zeroizing::new(Vec::new()); while let Some(chunk) = response @@ -134,54 +141,58 @@ pub(super) async fn prepare(target: &str, auth: ViewerAuth) -> Result, - bootstrap: Option<&builderlab::Bootstrap>, + defer_navigation: bool, ) -> Result { let policy = NavigationPolicy::new(app.config().build.dev_url.clone()); let target = policy.validate(target)?; @@ -272,7 +272,7 @@ fn build_contents( policy, }; platform::resize_guest(&browser.guest, &browser.host, bounds)?; - if bootstrap.is_none() { + if !defer_navigation { browser.navigate(target.as_str())?; } Ok(browser) @@ -287,20 +287,16 @@ pub async fn browser_attach( builderlab: Option, ) -> Result { let generation = DOCUMENT_GENERATION.load(Ordering::SeqCst); + let defer_navigation = builderlab.is_some(); let bootstrap = match builderlab { - Some(auth) => Some(builderlab::prepare(&url, auth).await?), + Some(auth) => builderlab::prepare(&url, auth).await?, None => None, }; let handle = app.clone(); 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(), - bootstrap.as_ref(), - )?; + 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)) diff --git a/src-tauri/src/browser/platform.rs b/src-tauri/src/browser/platform.rs index bb6ac9328..2f4e4b641 100644 --- a/src-tauri/src/browser/platform.rs +++ b/src-tauri/src/browser/platform.rs @@ -177,14 +177,14 @@ mod macos { 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.builderlab.xyz".into(), - cookie_domain: "apps.builderlab.xyz".into(), - cookie: zeroize::Zeroizing::new(format!("__Secure-bbidentity_hosted_site_session=viewer; Domain=apps.builderlab.xyz; Path=/; Expires={date}; Secure; HttpOnly; SameSite=Lax")), + 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.builderlab.xyz" + "apps.example" ); assert_eq!(cookie.path().to_string(), "/"); assert!(cookie.isHTTPOnly()); 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 f1bcff98b..204dfb306 100644 --- a/src/bundled/builderlab/apps/environment.ts +++ b/src/bundled/builderlab/apps/environment.ts @@ -51,26 +51,45 @@ export function isAppUrl(target: string) { url.protocol === "https:" && !url.username && !url.password && - APP_DOMAINS.some((domain) => host.endsWith(`.${domain}`)) + [...APP_DOMAINS, viewerAppDomain()].some( + (domain) => domain && host.endsWith(`.${domain}`), + ) ); } +/** 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), ) { - const domains: Readonly> = { - "https://app.builderlab.xyz": "apps.builderlab.xyz", - "https://app.builderlabstaging.xyz": "apps.builderlabstaging.xyz", - "https://block.builderlab.xyz": "apps.block.builderlab.xyz", - }; try { const url = new URL(target); - const domain = domains[new URL(origin).origin]; + const login = new URL(origin); const label = domain && url.hostname.slice(0, -(domain.length + 1)); return ( - isAppUrl(target) && + login.protocol === "https:" && + !login.username && + !login.password && + url.protocol === "https:" && + !url.username && + !url.password && Boolean( domain && url.port === "" && diff --git a/src/bundled/builderlab/apps/index.ts b/src/bundled/builderlab/apps/index.ts index 3e8f5057f..1804d47ff 100644 --- a/src/bundled/builderlab/apps/index.ts +++ b/src/bundled/builderlab/apps/index.ts @@ -6,7 +6,13 @@ 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, isViewerTarget } from "./environment"; +import { + appName, + controlPlane, + isAppUrl, + isViewerTarget, + viewerAppDomain, +} from "./environment"; import { EmptyState } from "../../../shared/design-system/ui/EmptyState"; import { Login } from "../login/Login"; @@ -46,7 +52,7 @@ export function applyApps(ctx: Ctx, session: OAuthSession) { session.snapshot, session.snapshot, ); - if (!isViewerTarget(target)) + if (viewerAppDomain() && !isViewerTarget(target)) return createElement(EmptyState, { icon: null, title: "Open this app with its Builderlab environment", diff --git a/src/bundled/builderlab/apps/viewer.test.tsx b/src/bundled/builderlab/apps/viewer.test.tsx index 4001bd018..5ff10e561 100644 --- a/src/bundled/builderlab/apps/viewer.test.tsx +++ b/src/bundled/builderlab/apps/viewer.test.tsx @@ -10,14 +10,15 @@ import { applyApps } from "./index"; import { deferred } from "../test-helpers"; import type { Credential } from "../oauth/browser"; -const APP = "https://counter--bpsites.apps.builderlab.xyz/report/42"; +const APP = "https://counter--workspace.apps.example/report/42"; afterEach(() => { cleanup(); vi.unstubAllEnvs(); }); function setup() { - vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://app.builderlab.xyz"); + 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: { @@ -117,7 +118,7 @@ it("an app from a different tenant never receives the Settings credential", asyn await session.signIn(); render( createElement(panel.component, { - target: "https://counter.apps.block.builderlab.xyz/", + target: "https://counter.apps.other.example/", } as PanelProps), ); expect( @@ -126,3 +127,19 @@ it("an app from a different tenant never receives the Settings credential", asyn expect(viewed).not.toHaveBeenCalled(); 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(lifetime?.storage).toBeTruthy(); + act(() => session.signOut()); + expect(lifetime?.signal.aborted).toBe(true); + expect(screen.queryByLabelText("App website")).not.toBeInTheDocument(); + session.dispose(); +}); diff --git a/src/features/browser/platform.test.ts b/src/features/browser/platform.test.ts index ba43ded8e..5de59808b 100644 --- a/src/features/browser/platform.test.ts +++ b/src/features/browser/platform.test.ts @@ -102,7 +102,7 @@ describe("createBrowserPlatform", () => { const lifetime = new AbortController(); const platform = createBrowserPlatform(); const attach = platform.attach( - "https://counter--bpsites.apps.builderlab.xyz/second", + "https://counter--workspace.apps.example/second", { x: 0, y: 0, width: 1, height: 1 }, "account", { credential: "private", storage: "account", signal: lifetime.signal }, @@ -120,7 +120,7 @@ describe("createBrowserPlatform", () => { 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--bpsites.apps.builderlab.xyz/second?q=1#part"; + const url = "https://counter--workspace.apps.example/second?q=1#part"; await createBrowserPlatform().attach( url, { x: 0, y: 0, width: 1, height: 1 }, diff --git a/tests/browser/apps-panel.spec.mjs b/tests/browser/apps-panel.spec.mjs index 0e83e807a..803451b8c 100644 --- a/tests/browser/apps-panel.spec.mjs +++ b/tests/browser/apps-panel.spec.mjs @@ -1,7 +1,5 @@ import { test, expect } from "./source-fixture.mjs"; -test.use({ builderlabOrigin: "https://app.builderlabstaging.xyz" }); - async function expectWebsiteVisible(page, since) { const previous = await page.evaluate( () => window.appsPanelCalls.presentations.length, diff --git a/tests/browser/source-fixture.mjs b/tests/browser/source-fixture.mjs index be6ce69d0..6732c47dd 100644 --- a/tests/browser/source-fixture.mjs +++ b/tests/browser/source-fixture.mjs @@ -6,19 +6,13 @@ import { createServer } from "./vite-server.mjs"; // Only stateless source serving is shared. Playwright still owns a fresh context // per test; fixtures with mutable server middleware must keep their own server. export const test = base.extend({ - builderlabOrigin: ["", { scope: "worker", option: true }], sourceOrigin: [ - async ({ builderlabOrigin }, use) => { + // biome-ignore lint/correctness/noEmptyPattern: Playwright requires a destructured fixture argument. + async ({}, use) => { const server = await createServer({ root: fileURLToPath(new URL("../../", import.meta.url)), configFile: false, envFile: false, - define: builderlabOrigin - ? { - "import.meta.env.VITE_BUZZ_BUILDERLAB_URL": - JSON.stringify(builderlabOrigin), - } - : {}, plugins: [react()], logLevel: "error", server: { host: "127.0.0.1", port: 0, strictPort: true }, diff --git a/vite.config.ts b/vite.config.ts index 9a2fa026d..3b785980a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -79,6 +79,8 @@ export default defineConfig(async ({ command, mode }) => { "import.meta.env.VITE_BUZZ_BUILDERLAB_URL": JSON.stringify( env.BUZZ_BUILDERLAB_URL ?? "", ), + "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), }, build: { From 4dfe3a5f39324ace4a9475600f2ca9bc11d0e3ba Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Tue, 27 Oct 2026 21:57:45 +0000 Subject: [PATCH 08/13] Keep browser login reachable when Settings login is unconfigured Co-authored-by: nthilly_hosted Signed-off-by: Nathan Thillairajah --- src/bundled/builderlab/apps/index.test.ts | 2 +- src/bundled/builderlab/apps/index.ts | 15 ++++++++++++++- src/bundled/builderlab/apps/viewer.test.tsx | 20 ++++++++++++++++++-- src/bundled/builderlab/index.tsx | 4 ++-- tests/fixtures/apps-panel.tsx | 1 + 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/bundled/builderlab/apps/index.test.ts b/src/bundled/builderlab/apps/index.test.ts index 10681fc22..7f8175b7b 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 1804d47ff..17ea1b4fa 100644 --- a/src/bundled/builderlab/apps/index.ts +++ b/src/bundled/builderlab/apps/index.ts @@ -43,7 +43,12 @@ 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) => { @@ -52,6 +57,14 @@ export function applyApps(ctx: Ctx, session: OAuthSession) { session.snapshot, session.snapshot, ); + // Without a Settings login there is no account to bootstrap from; keep the + // credential-free browser login the app has always had. + if (!loginAvailable) + return createElement(View, { + url: target, + storage: "builderlab-apps", + label: appName(target), + }); if (viewerAppDomain() && !isViewerTarget(target)) return createElement(EmptyState, { icon: null, diff --git a/src/bundled/builderlab/apps/viewer.test.tsx b/src/bundled/builderlab/apps/viewer.test.tsx index 5ff10e561..495c4b6f7 100644 --- a/src/bundled/builderlab/apps/viewer.test.tsx +++ b/src/bundled/builderlab/apps/viewer.test.tsx @@ -16,7 +16,7 @@ afterEach(() => { vi.unstubAllEnvs(); }); -function setup() { +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 () => ({ @@ -42,7 +42,7 @@ function setup() { agents: { addTool: vi.fn() }, host: {}, }; - applyApps(ctx as never, session); + applyApps(ctx as never, session, loginAvailable); const panel = register.mock.calls[0]?.[0] as { matches(target: string): boolean; component: ComponentType; @@ -143,3 +143,19 @@ it("an unconfigured native exchange keeps the account-isolated browser-login pat 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 8673619c1..ebac6ee85 100644 --- a/src/bundled/builderlab/index.tsx +++ b/src/bundled/builderlab/index.tsx @@ -57,7 +57,8 @@ export const apply: PluginModule["apply"] = (ctx) => { }, ); ctx.effect(() => () => session.dispose()); - applyApps(ctx, session); + const loginAvailable = browserLoginAvailable() && !unavailable; + applyApps(ctx, session, loginAvailable); const backupSession = () => { if (!nativeIdentityEnabled()) return; handoffBackupSession( @@ -86,7 +87,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/tests/fixtures/apps-panel.tsx b/tests/fixtures/apps-panel.tsx index be08089a5..6f8c34edf 100644 --- a/tests/fixtures/apps-panel.tsx +++ b/tests/fixtures/apps-panel.tsx @@ -69,6 +69,7 @@ await session.signIn(); applyApps( ctx.extend({ pluginOwner: { id: "fixture.apps", revision: "one" } }) as never, session, + true, ); const urls = [1, 2, 3, 4, 5].map( (id) => `https://app${id}--bpsites.apps.builderlabstaging.xyz/`, From 4e13a8a36fb9fb8ffcb3bbb60fdcd8c76829275f Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Wed, 28 Oct 2026 16:56:30 -0400 Subject: [PATCH 09/13] Hide closed app guests promptly and preserve other-tenant login Co-authored-by: Codex Signed-off-by: Nathan Thillairajah --- src/bundled/builderlab/apps/index.ts | 14 +--- src/bundled/builderlab/apps/viewer.test.tsx | 12 ++-- src/features/browser/sessions.test.ts | 76 +++++++++++++++++++++ src/features/browser/sessions.ts | 11 ++- 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/src/bundled/builderlab/apps/index.ts b/src/bundled/builderlab/apps/index.ts index 17ea1b4fa..a9ce3ace9 100644 --- a/src/bundled/builderlab/apps/index.ts +++ b/src/bundled/builderlab/apps/index.ts @@ -14,7 +14,6 @@ import { viewerAppDomain, } from "./environment"; -import { EmptyState } from "../../../shared/design-system/ui/EmptyState"; import { Login } from "../login/Login"; type Ctx = Parameters[0]; @@ -57,21 +56,14 @@ export function applyApps( session.snapshot, session.snapshot, ); - // Without a Settings login there is no account to bootstrap from; keep the - // credential-free browser login the app has always had. - if (!loginAvailable) + // 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 (viewerAppDomain() && !isViewerTarget(target)) - return createElement(EmptyState, { - icon: null, - title: "Open this app with its Builderlab environment", - description: - "This app uses a different Builderlab environment than this Buzz build.", - }); if (state.status !== "signed-in") return createElement(Login, { session, diff --git a/src/bundled/builderlab/apps/viewer.test.tsx b/src/bundled/builderlab/apps/viewer.test.tsx index 495c4b6f7..dbe712b15 100644 --- a/src/bundled/builderlab/apps/viewer.test.tsx +++ b/src/bundled/builderlab/apps/viewer.test.tsx @@ -113,7 +113,7 @@ it("closing an observing app panel does not cancel Settings' pending login", asy session.dispose(); }); -it("an app from a different tenant never receives the Settings credential", async () => { +it("an app from a different tenant keeps browser login without the Settings credential", async () => { const { session, viewed, panel } = setup(); await session.signIn(); render( @@ -121,10 +121,12 @@ it("an app from a different tenant never receives the Settings credential", asyn target: "https://counter.apps.other.example/", } as PanelProps), ); - expect( - screen.getByText("Open this app with its Builderlab environment"), - ).toBeInTheDocument(); - expect(viewed).not.toHaveBeenCalled(); + 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(); }); diff --git a/src/features/browser/sessions.test.ts b/src/features/browser/sessions.test.ts index 4f45dd0da..636e40796 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"; @@ -187,3 +188,78 @@ it("sign-out closes live and parked account guests even while another attach is 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("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 0581a70df..0bb60fa23 100644 --- a/src/features/browser/sessions.ts +++ b/src/features/browser/sessions.ts @@ -77,7 +77,14 @@ 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. + await hidden; if (park === undefined || !this.owns(sessionId)) return this.close(sessionId); this.shown.delete(sessionId); @@ -93,8 +100,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. */ From f2a7213f01da334852c914731a3f6e90a3a1b942 Mon Sep 17 00:00:00 2001 From: Bill Gao Date: Thu, 29 Oct 2026 15:40:32 +1100 Subject: [PATCH 10/13] Close a guest even when hiding it fails A detach whose native hide rejected stopped before close ran, so the guest stayed live or visible until eviction. Close it in that case, for both plain and parked detaches, while the hide error still reaches the caller. Signed-off-by: Bill Gao --- src/features/browser/sessions.test.ts | 19 +++++++++++++++++++ src/features/browser/sessions.ts | 9 +++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/features/browser/sessions.test.ts b/src/features/browser/sessions.test.ts index 636e40796..a9bf07f08 100644 --- a/src/features/browser/sessions.test.ts +++ b/src/features/browser/sessions.test.ts @@ -234,6 +234,25 @@ it.each([undefined, "a"])( }, ); +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(); diff --git a/src/features/browser/sessions.ts b/src/features/browser/sessions.ts index 0bb60fa23..db6ef5ed9 100644 --- a/src/features/browser/sessions.ts +++ b/src/features/browser/sessions.ts @@ -83,8 +83,13 @@ export class BrowserSessions { : Promise.resolve(); this.shown.delete(sessionId); const detached = this.enqueue(async () => { - // Re-adoption must not overtake the old view's in-flight hide. - await hidden; + // 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); From 00c8ef542f5646c8148ae5b325c5a11434f0e84f Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 13:02:03 -0400 Subject: [PATCH 11/13] Inset the signed-out Builderlab app prompt Co-authored-by: Codex Signed-off-by: Nathan Thillairajah --- src/bundled/builderlab/apps/index.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/bundled/builderlab/apps/index.ts b/src/bundled/builderlab/apps/index.ts index a9ce3ace9..aade66a67 100644 --- a/src/bundled/builderlab/apps/index.ts +++ b/src/bundled/builderlab/apps/index.ts @@ -65,12 +65,16 @@ export function applyApps( label: appName(target), }); if (state.status !== "signed-in") - return createElement(Login, { - session, - cancelOnUnmount: false, - available: true, - active: () => true, - }); + return createElement( + "div", + { className: "p-panel-inset" }, + createElement(Login, { + session, + cancelOnUnmount: false, + available: true, + active: () => true, + }), + ); return createElement(View, { url: target, builderlab: session.viewerSession(), From ebc5fe50406ba5aa279d17f85cac8cc16a72c6a4 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 13:31:47 -0400 Subject: [PATCH 12/13] Restore trusted app destinations after guest eviction Co-authored-by: Codex Signed-off-by: Nathan Thillairajah --- src/bundled/builderlab/apps/index.ts | 1 + src/bundled/builderlab/apps/viewer.test.tsx | 22 +++ src/features/browser/BrowserHostView.test.tsx | 131 +++++++++++++++--- src/features/browser/BrowserHostView.tsx | 18 ++- src/features/browser/api.ts | 2 + 5 files changed, 151 insertions(+), 23 deletions(-) diff --git a/src/bundled/builderlab/apps/index.ts b/src/bundled/builderlab/apps/index.ts index aade66a67..fddcd273f 100644 --- a/src/bundled/builderlab/apps/index.ts +++ b/src/bundled/builderlab/apps/index.ts @@ -78,6 +78,7 @@ export function applyApps( return createElement(View, { url: target, builderlab: session.viewerSession(), + canResumeUrl: viewerAppDomain() ? isViewerTarget : undefined, label: appName(target), }); }; diff --git a/src/bundled/builderlab/apps/viewer.test.tsx b/src/bundled/builderlab/apps/viewer.test.tsx index dbe712b15..eae1ed748 100644 --- a/src/bundled/builderlab/apps/viewer.test.tsx +++ b/src/bundled/builderlab/apps/viewer.test.tsx @@ -84,6 +84,27 @@ it("app links and apps_open use the Settings account and replace its guest lifet 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(); @@ -139,6 +160,7 @@ it("an unconfigured native exchange keeps the account-isolated browser-login pat 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); diff --git a/src/features/browser/BrowserHostView.test.tsx b/src/features/browser/BrowserHostView.test.tsx index f275a88f4..79c84e175 100644 --- a/src/features/browser/BrowserHostView.test.tsx +++ b/src/features/browser/BrowserHostView.test.tsx @@ -839,16 +839,67 @@ it("ignores status from a replaced session", async () => { expect(screen.queryByText("Old page")).not.toBeInTheDocument(); }); -it("restarts a lost Builderlab guest at the app URL instead of replaying its login ticket", async () => { +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 login = - "https://login.apps.builderlab.xyz/api/goose/v1/auth/hosted-site/handoff?code=one-use"; + const destination = "https://second.apps.example/report/42?q=week#chart"; vi.mocked(platform.status).mockResolvedValue({ - url: login, - title: "", - loading: true, + url: destination, + title: "Second app", + loading: false, error: null, }); const sessions = new BrowserSessions(platform); @@ -858,27 +909,67 @@ it("restarts a lost Builderlab guest at the app URL instead of replaying its log storage: "viewer-test", signal: lifetime.signal, }; - const app = "https://counter.apps.builderlab.xyz/report/42"; - render( - , + const canResumeUrl = vi.fn((target: string) => target === destination); + const view = render( +
+ +
, ); - // Establish that polling applied the intermediate navigation before losing this guest. await waitFor(() => - expect(screen.getByRole("textbox", { name: "Address" })).toHaveValue(login), + expect(screen.getByRole("textbox", { name: "Address" })).toHaveValue( + destination, + ), ); - await act(() => sessions.detach("guest-1")); - fireEvent(window, new Event("resize")); - await waitFor(() => expect(platform.attach).toHaveBeenCalledTimes(2)); + 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( - app, + 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 b76088de5..9322f3601 100644 --- a/src/features/browser/BrowserHostView.tsx +++ b/src/features/browser/BrowserHostView.tsx @@ -91,6 +91,7 @@ export function BrowserHostView({ url, storage: requestedStorage, builderlab, + canResumeUrl, label, platform, sessions, @@ -216,10 +217,12 @@ export function BrowserHostView({ try { const next = await platform.status(sessionId); if (!disposed && session.current === sessionId) { - // A lost auth/consent page must bootstrap again from the app, never replay its one-use ticket. + // Restore app destinations, not temporary auth/consent pages or their one-use tickets. if ( next.url && - (!builderlab || new URL(next.url).origin === new URL(url).origin) + (canResumeUrl + ? canResumeUrl(next.url) + : !builderlab || new URL(next.url).origin === new URL(url).origin) ) resumeUrl.current = next.url; sessions.rememberTitle(titleKey, next.title); @@ -351,7 +354,16 @@ export function BrowserHostView({ // say from another channel, so work in progress there survives. if (sessionId) void sessions.detach(sessionId, park); }; - }, [builderlab, 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 2fab72de4..9503a06a2 100644 --- a/src/features/browser/api.ts +++ b/src/features/browser/api.ts @@ -11,6 +11,8 @@ 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; /** 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. */ From 3567a53e583459beeb402d6994a69701396e986b Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 13:32:28 -0400 Subject: [PATCH 13/13] Allow an omitted app resume policy at call sites Co-authored-by: Codex Signed-off-by: Nathan Thillairajah --- src/features/browser/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/browser/api.ts b/src/features/browser/api.ts index 9503a06a2..e45f4bb25 100644 --- a/src/features/browser/api.ts +++ b/src/features/browser/api.ts @@ -12,7 +12,7 @@ export type BrowserViewProps = Readonly<{ /** 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; + 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. */