diff --git a/Justfile b/Justfile index 3cd03874538..4a7a3d570de 100644 --- a/Justfile +++ b/Justfile @@ -255,7 +255,18 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \ BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \ cargo test compiled_policy_matches_expected -- --ignored --nocapture - echo "Both compiled states verified." + echo "=== Maximum accepted demo name reaches Rust build validation ===" + DEMO_CONFIG="$(node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..31})" /dev/null 1234567812345678)" + DEMO_SLUG="$(node -e 'console.log(JSON.parse(process.argv[1]).slug)' "$DEMO_CONFIG")" + BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" \ + BUZZ_TEST_EXPECTED_DEMO_SLUG="$DEMO_SLUG" \ + cargo test compiled_demo_slug_matches_expected -- --ignored --nocapture + BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" cargo test --workspace + if node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..32})" /dev/null 1234567812345678; then + echo "A 32-character demo name unexpectedly passed JavaScript validation" >&2 + exit 1 + fi + echo "Both compiled states and the accepted/rejected demo-name boundary verified." # Build the full desktop Tauri app locally (unsigned, for testing) # Sidecar binary list must stay in sync with _ensure-sidecar-stubs above. @@ -276,6 +287,38 @@ desktop-release-build target="aarch64-apple-darwin": pnpm install cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}} +# Build an unsigned named macOS demo DMG with isolated app and runtime identities. +desktop-demo-build demo_name target="aarch64-apple-darwin": + #!/usr/bin/env bash + set -euo pipefail + TARGET={{target}} + [[ "$(uname -s)" == "Darwin" && "$TARGET" == *-apple-darwin ]] || { echo "Demo DMGs require a macOS Apple target" >&2; exit 2; } + CONFIG_PATH="$(mktemp "${TMPDIR:-/tmp}/buzz-demo-config.XXXXXX")" + trap 'rm -f "$CONFIG_PATH"' EXIT + DEMO_BUILD_ID="$(node -e 'console.log(require("node:crypto").randomBytes(8).toString("hex"))')" + DEMO_CONFIG="$(node desktop/scripts/demo-build-config.mjs {{quote(demo_name)}} "$CONFIG_PATH" "$DEMO_BUILD_ID")" + read_config() { node -e 'console.log(JSON.parse(process.argv[1])[process.argv[2]])' "$DEMO_CONFIG" "$1"; } + PRODUCT_NAME="$(read_config productName)" + DMG_VOLUME_NAME="$(read_config dmgVolumeName)" + DMG_FILE_STEM="$(read_config dmgFileStem)" + DEMO_SLUG="$(read_config slug)" + cargo build --release --target "$TARGET" \ + -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp \ + -p git-credential-nostr -p buzz-cli + ./scripts/bundle-sidecars.sh "$TARGET" + pnpm install + cd {{desktop_dir}} + BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" pnpm tauri build --features mesh-llm --target "$TARGET" --bundles app --config "$CONFIG_PATH" + cd .. + VERSION="$(node -p "require('./desktop/package.json').version")" + DMG_ARCH="${TARGET%%-*}"; [[ "$DMG_ARCH" == "x86_64" ]] && DMG_ARCH=x64 + APP_PATH="desktop/src-tauri/target/$TARGET/release/bundle/macos/$PRODUCT_NAME.app" + PLIST="$APP_PATH/Contents/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $PRODUCT_NAME" "$PLIST" + /usr/libexec/PlistBuddy -c "Set :CFBundleName $PRODUCT_NAME" "$PLIST" + codesign --force --deep --sign - "$APP_PATH" + VOL_NAME="$DMG_VOLUME_NAME" ./desktop/scripts/package-macos-dmg.sh "$APP_PATH" "desktop/src-tauri/target/$TARGET/release/bundle/dmg/${DMG_FILE_STEM}_${VERSION}_${DMG_ARCH}.dmg" + # Run desktop checks suitable for CI / pre-push desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index a78a499bdd1..7ebabccbbbd 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -93,8 +93,9 @@ impl TokenSource for StaticTokenSource { /// /// The `discovery_url` must return a JSON document with at least /// `authorization_endpoint` and `token_endpoint` (RFC 8414). The -/// `cache_namespace` is the directory under `~/.config/buzz-agent/oauth/` -/// the token JSON lives in — separates providers' caches cleanly. +/// `cache_namespace` is the directory under the platform config directory's +/// `buzz-agent/oauth/` root where the token JSON lives — separates providers' +/// caches cleanly. #[derive(Debug, Clone)] pub struct PkceOAuthConfig { pub discovery_url: String, @@ -102,7 +103,7 @@ pub struct PkceOAuthConfig { pub scopes: Vec, pub cache_namespace: String, /// When `Some`, the engine writes tokens here instead of - /// `~/.config/buzz-agent/oauth//`. Production code + /// `/buzz-agent/oauth//`. Production code /// leaves this `None`. Integration tests use it to avoid stomping on /// a shared `$HOME` when running in parallel. pub cache_dir_override: Option, @@ -444,6 +445,29 @@ fn is_expired(t: &CachedToken) -> bool { now + TOKEN_REFRESH_LEEWAY.as_secs() >= exp } +const BUZZ_AGENT_CONFIG_DIR_ENV: &str = "BUZZ_AGENT_CONFIG_DIR"; + +fn oauth_cache_root_for( + config_override: Option, + home_dir: Option, +) -> Result { + if let Some(root) = config_override { + return Ok(root.join("buzz-agent").join("oauth")); + } + Ok(home_dir + .ok_or_else(|| AgentError::Llm("oauth cache: home directory not found".into()))? + .join(".config") + .join("buzz-agent") + .join("oauth")) +} + +fn default_oauth_cache_root() -> Result { + oauth_cache_root_for( + std::env::var_os(BUZZ_AGENT_CONFIG_DIR_ENV).map(PathBuf::from), + dirs::home_dir(), + ) +} + fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { let mut h = sha2::Sha256::new(); h.update(cfg.discovery_url.as_bytes()); @@ -455,12 +479,7 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { let dir = match &cfg.cache_dir_override { Some(p) => p.join(&cfg.cache_namespace), - None => dirs::home_dir() - .ok_or_else(|| AgentError::Llm("oauth cache: home directory not found".into()))? - .join(".config") - .join("buzz-agent") - .join("oauth") - .join(&cfg.cache_namespace), + None => default_oauth_cache_root()?.join(&cfg.cache_namespace), }; Ok(dir.join(format!("{hash}.json"))) } @@ -862,7 +881,41 @@ mod tests { } #[test] - fn cache_path_uses_platform_home_directory() { + fn production_and_demo_oauth_roots_are_concrete_and_distinct() { + let home = PathBuf::from("/Users/demo"); + let production = oauth_cache_root_for(None, Some(home.clone())).unwrap(); + let first_demo_config = home + .join("Library/Application Support") + .join("buzz-demo-board-1234567812345678"); + let second_demo_config = home + .join("Library/Application Support") + .join("buzz-demo-board-8765432187654321"); + let first_demo = oauth_cache_root_for(Some(first_demo_config), Some(home.clone())).unwrap(); + let second_demo = oauth_cache_root_for(Some(second_demo_config), Some(home)).unwrap(); + + assert_eq!( + production, + PathBuf::from("/Users/demo/.config/buzz-agent/oauth") + ); + assert_eq!( + first_demo, + PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-1234567812345678/buzz-agent/oauth" + ) + ); + assert_eq!( + second_demo, + PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-8765432187654321/buzz-agent/oauth" + ) + ); + assert_ne!(production, first_demo); + assert_ne!(production, second_demo); + assert_ne!(first_demo, second_demo); + } + + #[test] + fn cache_path_preserves_production_home_config_directory() { let cfg = PkceOAuthConfig { discovery_url: "https://example.com/.well-known".into(), client_id: "abc".into(), diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index 82f3b086cd6..f2cda834fd1 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -12,7 +12,7 @@ //! This helper never opens a browser. Callers choose whether to reject, degrade, //! or start a separate interactive authentication flow. -use std::{collections::HashSet, sync::Arc, time::Duration}; +use std::{collections::HashSet, path::Path, sync::Arc, time::Duration}; use reqwest::Client; use serde_json::Value; @@ -133,7 +133,26 @@ pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool { /// # Panics /// Never panics. pub async fn discover_databricks_models(cfg: &Config) -> Result, AgentError> { - discover_databricks_models_with_token_source(cfg, build_token_source(cfg)?).await + discover_databricks_models_with_cache_dir(cfg, None).await +} + +/// Discover Databricks models while storing PKCE credentials under an explicit +/// cache root. `None` preserves buzz-agent's production cache location. +pub async fn discover_databricks_models_with_cache_dir( + cfg: &Config, + cache_dir: Option<&Path>, +) -> Result, AgentError> { + let token_source = if matches!(cfg.provider, Provider::Databricks | Provider::DatabricksV2) + && cfg.api_key.is_empty() + { + crate::auth::PkceOAuthTokenSource::new(crate::llm::databricks_pkce_config( + &cfg.base_url, + cache_dir.map(Path::to_path_buf), + ))? + } else { + build_token_source(cfg)? + }; + discover_databricks_models_with_token_source(cfg, token_source).await } async fn discover_databricks_models_with_token_source( diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index b094a0f9fd7..3de47c82a4a 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -13,7 +13,9 @@ mod permission; pub mod types; mod wire; -pub use catalog::{discover_databricks_models, ModelEntry}; +pub use catalog::{ + discover_databricks_models, discover_databricks_models_with_cache_dir, ModelEntry, +}; pub use config::Provider; pub use types::AgentError; @@ -161,10 +163,22 @@ pub fn run() -> Result<(), Box> { Ok(()) } +/// Authenticate to Databricks and store credentials under an optional explicit +/// cache root. `None` preserves buzz-agent's production cache location. +pub async fn authenticate_databricks_with_cache_dir( + host: &str, + cache_dir: Option<&std::path::Path>, +) -> Result<(), AgentError> { + auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config( + host, + cache_dir.map(std::path::Path::to_path_buf), + ))? + .interactive_login() + .await +} + pub async fn authenticate_databricks(host: &str) -> Result<(), AgentError> { - auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config(host))? - .interactive_login() - .await + authenticate_databricks_with_cache_dir(host, None).await } /// `buzz-agent auth ` — run the interactive auth flow for a diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 55c85bb0c5a..662b8ef964d 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -1,4 +1,5 @@ use std::collections::BTreeSet; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -2032,7 +2033,10 @@ where ))) } -pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { +pub(crate) fn databricks_pkce_config( + host: &str, + cache_dir_override: Option, +) -> PkceOAuthConfig { PkceOAuthConfig { discovery_url: format!( "{}/oidc/.well-known/oauth-authorization-server", @@ -2044,7 +2048,7 @@ pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { .map(|scope| (*scope).into()) .collect(), cache_namespace: "databricks".into(), - cache_dir_override: None, + cache_dir_override, } } @@ -2069,6 +2073,7 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A } Ok(PkceOAuthTokenSource::new(databricks_pkce_config( &cfg.base_url, + None, ))?) } } diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 42c9cc48780..a848557ae2f 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -45,6 +45,9 @@ const PASSTHROUGH_ENV: &[&str] = &[ "LC_ALL", "TMPDIR", "XDG_CONFIG_HOME", + // Explicit Buzz-owned OAuth root for named demo builds. The agent may spawn + // auth-capable child tools after clearing its ambient environment. + "BUZZ_AGENT_CONFIG_DIR", // SSH — required for git clone/push over SSH (git@github.com:...) "SSH_AUTH_SOCK", "SSH_AGENT_PID", diff --git a/desktop/package.json b/desktop/package.json index 1e93fd76a85..3272c37c1d9 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -14,7 +14,7 @@ "lint": "biome lint .", "check": "biome check . && pnpm check:px-text && pnpm check:pubkey-truncation", "format": "biome format --write .", - "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", + "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\" \"scripts/*.test.mjs\"", "preview": "vite preview", "tauri": "tauri", "test:e2e": "pnpm build:e2e && playwright test", diff --git a/desktop/scripts/demo-build-config.mjs b/desktop/scripts/demo-build-config.mjs new file mode 100644 index 00000000000..fd5c9ed2a1c --- /dev/null +++ b/desktop/scripts/demo-build-config.mjs @@ -0,0 +1,94 @@ +import { randomBytes } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const PRODUCTION_IDENTIFIER = "xyz.block.buzz.app"; +// The build ID suffix is 17 characters including its separator, and the Rust +// build contract caps the complete demo slug at 48 ASCII bytes. +const MAX_DEMO_SLUG_LENGTH = 48; +const DEMO_BUILD_ID_SUFFIX_LENGTH = 17; +const MAX_DEMO_NAME_LENGTH = MAX_DEMO_SLUG_LENGTH - DEMO_BUILD_ID_SUFFIX_LENGTH; + +export const productionBuildIdentity = Object.freeze({ + productName: "Buzz", + identifier: PRODUCTION_IDENTIFIER, + deepLinkScheme: "buzz", + keyringService: "buzz-desktop", + nestName: ".buzz", + cliName: "buzz", +}); + +export function demoBuildConfig( + rawName, + buildId = randomBytes(8).toString("hex"), +) { + if (typeof rawName !== "string") throw new Error("Demo name must be text"); + const name = rawName.trim().replace(/\s+/g, " "); + if (!name) throw new Error("Demo name must not be empty"); + if (name.length > MAX_DEMO_NAME_LENGTH) { + throw new Error( + `Demo name must be at most ${MAX_DEMO_NAME_LENGTH} characters`, + ); + } + if (!/^[A-Za-z0-9][A-Za-z0-9 -]*$/.test(name)) { + throw new Error( + "Demo name may contain ASCII letters, numbers, spaces, and hyphens only", + ); + } + + if (!/^[a-f0-9]{16}$/.test(buildId)) { + throw new Error( + "Demo build ID must be sixteen lowercase hexadecimal characters", + ); + } + + const readableSlug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const slug = `${readableSlug}-${buildId}`; + const productName = `Buzz ${name}`; + return { + name, + slug, + productName, + dmgVolumeName: productName, + dmgFileStem: productName.replace(/ /g, "_"), + identifier: `${PRODUCTION_IDENTIFIER}.demo.${slug}`, + appDataIdentity: `${PRODUCTION_IDENTIFIER}.demo.${slug}`, + deepLinkScheme: `buzz-demo-${slug}`, + keyringService: `buzz-desktop-demo.${slug}`, + nestName: `.buzz-demo-${slug}`, + cliName: `buzz-demo-${slug}`, + tauriConfig: { + productName, + identifier: `${PRODUCTION_IDENTIFIER}.demo.${slug}`, + plugins: { "deep-link": { desktop: { schemes: [`buzz-demo-${slug}`] } } }, + bundle: { targets: ["app"] }, + }, + }; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const [name, outputPath, buildId] = process.argv.slice(2); + if (!outputPath) { + console.error( + "Usage: demo-build-config.mjs ", + ); + process.exit(2); + } + try { + const config = demoBuildConfig(name, buildId); + writeFileSync( + outputPath, + `${JSON.stringify(config.tauriConfig, null, 2)}\n`, + ); + console.log(JSON.stringify(config)); + } catch (error) { + console.error(`Invalid demo build: ${error.message}`); + process.exit(1); + } +} diff --git a/desktop/scripts/demo-build-config.test.mjs b/desktop/scripts/demo-build-config.test.mjs new file mode 100644 index 00000000000..db2ba568c7b --- /dev/null +++ b/desktop/scripts/demo-build-config.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + demoBuildConfig, + productionBuildIdentity, +} from "./demo-build-config.mjs"; + +const expected = (name, slug) => ({ + name, + slug, + productName: `Buzz ${name}`, + dmgVolumeName: `Buzz ${name}`, + dmgFileStem: `Buzz_${name.replace(/ /g, "_")}`, + identifier: `xyz.block.buzz.app.demo.${slug}`, + appDataIdentity: `xyz.block.buzz.app.demo.${slug}`, + deepLinkScheme: `buzz-demo-${slug}`, + keyringService: `buzz-desktop-demo.${slug}`, + nestName: `.buzz-demo-${slug}`, + cliName: `buzz-demo-${slug}`, + tauriConfig: { + productName: `Buzz ${name}`, + identifier: `xyz.block.buzz.app.demo.${slug}`, + plugins: { "deep-link": { desktop: { schemes: [`buzz-demo-${slug}`] } } }, + bundle: { targets: ["app"] }, + }, +}); + +test("production identity remains unchanged", () => { + assert.deepEqual(productionBuildIdentity, { + productName: "Buzz", + identifier: "xyz.block.buzz.app", + deepLinkScheme: "buzz", + keyringService: "buzz-desktop", + nestName: ".buzz", + cliName: "buzz", + }); +}); + +test("two demo names produce complete, distinct identities", () => { + const board = demoBuildConfig("Workstream Board", "27a4294c27a4294c"); + const interests = demoBuildConfig("Interests Demo", "deb5339adeb5339a"); + assert.deepEqual( + board, + expected("Workstream Board", "workstream-board-27a4294c27a4294c"), + ); + assert.deepEqual( + interests, + expected("Interests Demo", "interests-demo-deb5339adeb5339a"), + ); + for (const key of [ + "productName", + "dmgVolumeName", + "dmgFileStem", + "identifier", + "appDataIdentity", + "deepLinkScheme", + "keyringService", + "nestName", + "cliName", + ]) { + assert.notEqual(board[key], interests[key], key); + assert.notEqual(board[key], productionBuildIdentity[key], key); + } +}); + +test("normalized spelling aliases retain distinct runtime identities", () => { + for (const [leftName, rightName] of [ + ["A B", "A-B"], + ["Demo", "demo"], + ["Workstream Board", "WORKSTREAM BOARD"], + ]) { + const left = demoBuildConfig(leftName, "1111111111111111"); + const right = demoBuildConfig(rightName, "2222222222222222"); + assert.notEqual(left.slug, right.slug); + for (const key of [ + "identifier", + "appDataIdentity", + "deepLinkScheme", + "keyringService", + "nestName", + "cliName", + ]) { + assert.notEqual( + left[key], + right[key], + `${leftName}/${rightName}: ${key}`, + ); + } + } +}); + +test("the same display name gets a distinct identity for each build", () => { + const first = demoBuildConfig("Demo", "1111111111111111"); + const second = demoBuildConfig("Demo", "2222222222222222"); + assert.equal(first.productName, second.productName); + assert.equal(first.dmgFileStem, second.dmgFileStem); + for (const key of [ + "slug", + "identifier", + "appDataIdentity", + "deepLinkScheme", + "keyringService", + "nestName", + "cliName", + ]) { + assert.notEqual(first[key], second[key], key); + } +}); + +test("whitespace normalization preserves deterministic identity", () => { + assert.deepEqual( + demoBuildConfig(" Workstream Board ", "27a4294c27a4294c"), + demoBuildConfig("Workstream Board", "27a4294c27a4294c"), + ); +}); + +test("maximum-length name produces a Rust-valid 48-byte slug", () => { + const config = demoBuildConfig("x".repeat(31), "1234567812345678"); + assert.equal(config.slug.length, 48); + assert.match(config.slug, /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/); +}); + +for (const name of [ + "", + " ", + "Workstream/Board", + "Workstream_Board", + "équipe", + "x".repeat(32), +]) { + test(`rejects unusable name ${JSON.stringify(name)}`, () => + assert.throws(() => demoBuildConfig(name, "1234567812345678"))); +} diff --git a/desktop/scripts/package-macos-dmg.sh b/desktop/scripts/package-macos-dmg.sh new file mode 100755 index 00000000000..7ecaf9502e8 --- /dev/null +++ b/desktop/scripts/package-macos-dmg.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Build a drag-to-Applications DMG without requiring a GUI login session. +# Finder styling is optional; the disk image itself is always authoritative. + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +app_path="$1" +out_dmg="$2" +app_name="$(basename "$app_path")" +volume_name="${VOL_NAME:-Buzz}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +background="$script_dir/../src-tauri/icons/dmg-background.png" +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/buzz-dmg.XXXXXX")" +source_dir="$work_dir/source" +rw_dmg="$work_dir/read-write.dmg" +mount_point="$work_dir/mount" +applescript="$work_dir/style.applescript" +device="" + +finish() { + local status="$?" + trap - EXIT + if [[ -n "$device" ]]; then + hdiutil detach "$device" >/dev/null 2>&1 || true + hdiutil detach -force "$device" >/dev/null 2>&1 || true + fi + rm -rf "$work_dir" + exit "$status" +} +trap finish EXIT + +[[ -d "$app_path" ]] || { echo "App bundle not found: $app_path" >&2; exit 1; } +[[ -f "$background" ]] || { echo "DMG background not found: $background" >&2; exit 1; } + +mkdir -p "$(dirname "$out_dmg")" "$source_dir/.background" "$mount_point" +ditto "$app_path" "$source_dir/$app_name" +ln -s /Applications "$source_dir/Applications" +cp "$background" "$source_dir/.background/background.png" + +rm -f "$rw_dmg" "$out_dmg" +hdiutil create -volname "$volume_name" -srcfolder "$source_dir" \ + -format UDRW -ov "$rw_dmg" >/dev/null + +attach_output="$(hdiutil attach -readwrite -noverify -noautoopen -nobrowse \ + -mountpoint "$mount_point" "$rw_dmg")" +device="$(printf '%s\n' "$attach_output" | awk '/^\/dev\// { print $1; exit }')" +[[ -n "$device" ]] || { echo "Failed to attach writable DMG" >&2; exit 1; } + +detach() { + local attempt + for attempt in 1 2 3 4 5; do + if hdiutil detach "$device" >/dev/null 2>&1; then + device="" + return 0 + fi + sleep 1 + done + hdiutil detach -force "$device" >/dev/null + device="" +} + +if command -v SetFile >/dev/null 2>&1; then + SetFile -a V "$mount_point/.background" || true + icon="$mount_point/$app_name/Contents/Resources/icon.icns" + if [[ -f "$icon" ]]; then + cp "$icon" "$mount_point/.VolumeIcon.icns" || true + SetFile -c icnC "$mount_point/.VolumeIcon.icns" || true + SetFile -a C "$mount_point" || true + fi +fi + +cat >"$applescript" <<'APPLESCRIPT' +on run argv + set mountPath to item 1 of argv + set appName to item 2 of argv + tell application "Finder" + set rootFolder to POSIX file mountPath as alias + open rootFolder + set imageWindow to container window of rootFolder + set current view of imageWindow to icon view + set toolbar visible of imageWindow to false + set statusbar visible of imageWindow to false + set bounds of imageWindow to {200, 120, 860, 652} + set viewOptions to icon view options of imageWindow + set arrangement of viewOptions to not arranged + set icon size of viewOptions to 128 + set text size of viewOptions to 14 + set background picture of viewOptions to file ".background:background.png" of rootFolder + set position of item appName of rootFolder to {191, 330} + set position of item "Applications" of rootFolder to {469, 330} + set extension hidden of item appName of rootFolder to true + delay 1 + close imageWindow + end tell +end run +APPLESCRIPT + +style_with_finder() { + local child elapsed=0 + /usr/bin/osascript "$applescript" "$mount_point" "$app_name" & + child=$! + while kill -0 "$child" 2>/dev/null; do + if (( elapsed >= 100 )); then + echo "Finder styling timed out; continuing without it" >&2 + kill "$child" 2>/dev/null || true + wait "$child" 2>/dev/null || true + return 124 + fi + sleep 0.1 + elapsed=$((elapsed + 1)) + done + wait "$child" +} + +if ! style_with_finder; then + echo "Finder styling unavailable; continuing without it" >&2 +fi + +sync +detach +hdiutil convert "$rw_dmg" -format UDZO -imagekey zlib-level=9 \ + -o "$out_dmg" >/dev/null +printf 'DMG ready: %s\n' "$out_dmg" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2cdd785c735..8b0e63f12bc 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -18,8 +18,29 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DEMO_SLUG"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); + if let Ok(slug) = std::env::var("BUZZ_BUILD_DEMO_SLUG") { + let valid = !slug.is_empty() + && slug.len() <= 48 + && slug + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && slug + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + && slug + .bytes() + .last() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + if !valid { + panic!("BUZZ_BUILD_DEMO_SLUG must be a lowercase ASCII slug"); + } + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_DEMO_SLUG={slug}"); + } + // Explicit owner-only agent-access capability. Release packaging sets this // presence-only marker; OSS/custom builds leave agent access configurable. if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() { diff --git a/desktop/src-tauri/src/app_state_keyring.rs b/desktop/src-tauri/src/app_state_keyring.rs index 68d24e87f58..7684355a5bc 100644 --- a/desktop/src-tauri/src/app_state_keyring.rs +++ b/desktop/src-tauri/src/app_state_keyring.rs @@ -7,7 +7,12 @@ fn dev_keyring_service(configured: Option) -> String { } pub(crate) fn keyring_service() -> &'static str { - if cfg!(debug_assertions) { + if crate::build_identity::is_demo_build() { + static DEMO_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); + DEMO_SERVICE + .get_or_init(|| crate::build_identity::keyring_service().into_owned()) + .as_str() + } else if cfg!(debug_assertions) { static DEV_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); DEV_SERVICE .get_or_init(|| dev_keyring_service(std::env::var("BUZZ_DEV_KEYRING_SERVICE").ok())) diff --git a/desktop/src-tauri/src/build_identity.rs b/desktop/src-tauri/src/build_identity.rs new file mode 100644 index 00000000000..ee84696c7f0 --- /dev/null +++ b/desktop/src-tauri/src/build_identity.rs @@ -0,0 +1,183 @@ +//! Compile-time identity for reusable named demo builds. +//! +//! Production builds leave `BUZZ_DESKTOP_BUILD_DEMO_SLUG` unset and retain all +//! existing names. The demo recipe validates one slug and `build.rs` bakes it +//! into the binary; every runtime identity is then derived from that one value. + +use std::borrow::Cow; + +pub(crate) fn demo_slug() -> Option<&'static str> { + option_env!("BUZZ_DESKTOP_BUILD_DEMO_SLUG") +} + +pub(crate) fn is_demo_build() -> bool { + demo_slug().is_some() +} + +pub(crate) const DEMO_AGENT_CONFIG_ENV: &str = "BUZZ_AGENT_CONFIG_DIR"; + +pub(crate) fn demo_config_home() -> Result, String> { + demo_config_home_for(demo_slug(), dirs::config_dir()) +} + +pub(crate) fn demo_agent_oauth_cache_dir() -> Result, String> { + Ok(demo_config_home()?.map(|dir| dir.join("buzz-agent").join("oauth"))) +} + +/// Keep child config caches inside this demo build's identity. In particular, +/// bundled buzz-agent OAuth tokens must not read or write production's root. +/// Refuse launch if a demo cannot resolve its root; None means production only. +pub(crate) fn apply_demo_config_home(command: &mut std::process::Command) -> Result<(), String> { + if let Some(config_home) = demo_config_home()? { + command.env(DEMO_AGENT_CONFIG_ENV, config_home); + } + Ok(()) +} + +fn demo_config_home_for( + demo_slug: Option<&str>, + config_dir: Option, +) -> Result, String> { + match demo_slug { + None => Ok(None), + Some(slug) => config_dir + .map(|dir| Some(dir.join(format!("buzz-demo-{slug}")))) + .ok_or_else(|| "cannot resolve demo credential directory".to_string()), + } +} + +pub(crate) fn deep_link_scheme() -> Cow<'static, str> { + demo_slug() + .map(|slug| Cow::Owned(format!("buzz-demo-{slug}"))) + .unwrap_or(Cow::Borrowed("buzz")) +} + +pub(crate) fn is_deep_link_for_build(value: &str) -> bool { + is_deep_link_for_scheme(value, deep_link_scheme().as_ref()) +} + +fn is_deep_link_for_scheme(value: &str, scheme: &str) -> bool { + value + .strip_prefix(scheme) + .is_some_and(|suffix| suffix.starts_with("://")) +} + +pub(crate) fn keyring_service() -> Cow<'static, str> { + demo_slug() + .map(|slug| Cow::Owned(format!("buzz-desktop-demo.{slug}"))) + .unwrap_or(Cow::Borrowed("buzz-desktop")) +} + +pub(crate) fn nest_name(is_dev: bool) -> Cow<'static, str> { + nest_name_for(demo_slug(), is_dev) +} + +fn nest_name_for(demo_slug: Option<&str>, is_dev: bool) -> Cow<'_, str> { + if let Some(slug) = demo_slug { + Cow::Owned(format!(".buzz-demo-{slug}")) + } else if is_dev { + Cow::Borrowed(".buzz-dev") + } else { + Cow::Borrowed(".buzz") + } +} + +pub(crate) fn cli_name(is_dev: bool) -> String { + if let Some(slug) = demo_slug() { + format!("buzz-demo-{slug}") + } else if is_dev { + "buzz-dev".to_string() + } else { + "buzz".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "compiled with BUZZ_BUILD_DEMO_SLUG by the compiled-flags recipe"] + fn compiled_demo_slug_matches_expected() { + let expected = std::env::var("BUZZ_TEST_EXPECTED_DEMO_SLUG") + .expect("BUZZ_TEST_EXPECTED_DEMO_SLUG must be set"); + assert_eq!(demo_slug(), Some(expected.as_str())); + } + + #[test] + fn ordinary_release_defaults_remain_production_identity() { + if demo_slug().is_none() { + assert_eq!(deep_link_scheme(), "buzz"); + assert_eq!(keyring_service(), "buzz-desktop"); + assert_eq!(nest_name(false), ".buzz"); + assert_eq!(cli_name(false), "buzz"); + } + } + + #[test] + fn demo_agent_config_and_oauth_roots_are_build_scoped() { + let base = std::path::PathBuf::from("/Users/demo/Library/Application Support"); + assert_eq!( + demo_config_home_for(None, Some(base.clone())).unwrap(), + None + ); + let first = demo_config_home_for(Some("board-1234567812345678"), Some(base.clone())) + .unwrap() + .unwrap(); + let second = demo_config_home_for(Some("board-8765432187654321"), Some(base)) + .unwrap() + .unwrap(); + assert_eq!( + first, + std::path::PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-1234567812345678" + ) + ); + assert_eq!( + first.join("buzz-agent/oauth"), + std::path::PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-1234567812345678/buzz-agent/oauth" + ) + ); + assert_ne!(first, second); + } + + #[test] + fn unresolved_demo_credentials_never_select_production_defaults() { + assert_eq!(demo_config_home_for(None, None).unwrap(), None); + assert_eq!( + demo_config_home_for(Some("board-1234567812345678"), None), + Err("cannot resolve demo credential directory".to_string()) + ); + } + + #[test] + fn duplicate_instance_links_follow_the_build_scheme() { + assert!(is_deep_link_for_scheme("buzz://message?id=1", "buzz")); + assert!(!is_deep_link_for_scheme( + "buzz-demo-board-1234567812345678://message?id=1", + "buzz" + )); + assert!(is_deep_link_for_scheme( + "buzz-demo-board-1234567812345678://message?id=1", + "buzz-demo-board-1234567812345678" + )); + assert!(!is_deep_link_for_scheme( + "buzz://message?id=1", + "buzz-demo-board-1234567812345678" + )); + } + + #[test] + fn production_and_named_demo_nests_are_distinct() { + assert_eq!(nest_name_for(None, false), ".buzz"); + assert_eq!( + nest_name_for(Some("workstream-board"), false), + ".buzz-demo-workstream-board" + ); + assert_eq!( + nest_name_for(Some("second-demo"), false), + ".buzz-demo-second-demo" + ); + } +} diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 998edeca27d..f671983bbc6 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -54,6 +54,8 @@ pub(super) async fn run_agent_models_command( for (k, v) in &merged_env { cmd.env(k, v); } + // Demo identity is authoritative and must win over ambient/user env. + crate::build_identity::apply_demo_config_home(&mut cmd)?; crate::managed_agents::configure_runtime_cli(&mut cmd, known_acp_runtime(&agent_command)); crate::util::configure_no_window(&mut cmd); cmd.stdout(std::process::Stdio::piped()) diff --git a/desktop/src-tauri/src/commands/agent_models_databricks.rs b/desktop/src-tauri/src/commands/agent_models_databricks.rs index 1f66f24c6a3..07f19f9a204 100644 --- a/desktop/src-tauri/src/commands/agent_models_databricks.rs +++ b/desktop/src-tauri/src/commands/agent_models_databricks.rs @@ -178,12 +178,23 @@ pub(super) async fn discover_databricks_models( parsed_filter.clone(), ); let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &api_key); + let oauth_cache_dir = crate::build_identity::demo_agent_oauth_cache_dir()?; - let entries = match buzz_agent_pkg::discover_databricks_models(&config).await { + let entries = match buzz_agent_pkg::discover_databricks_models_with_cache_dir( + &config, + oauth_cache_dir.as_deref(), + ) + .await + { Ok(entries) => entries, Err(buzz_agent_pkg::AgentError::LlmAuth(_)) if should_start_interactive_auth(&api_key) => { let _auth = AUTH_GATE.lock().await; - match buzz_agent_pkg::discover_databricks_models(&config).await { + match buzz_agent_pkg::discover_databricks_models_with_cache_dir( + &config, + oauth_cache_dir.as_deref(), + ) + .await + { // A peer sign-in under the gate already succeeded. Ok(entries) => entries, Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { @@ -194,22 +205,28 @@ pub(super) async fn discover_databricks_models( return Err(databricks_sign_in_required_error()); } run_interactive_databricks_auth( - buzz_agent_pkg::authenticate_databricks(&host), + buzz_agent_pkg::authenticate_databricks_with_cache_dir( + &host, + oauth_cache_dir.as_deref(), + ), AUTH_FLOW_TIMEOUT, &AUTH_COOLDOWNS, &host, &redaction_env, ) .await?; - buzz_agent_pkg::discover_databricks_models(&config) - .await - .map_err(|error| { - format_redacted_error( - "Databricks model discovery failed after sign-in", - &error, - &redaction_env, - ) - })? + buzz_agent_pkg::discover_databricks_models_with_cache_dir( + &config, + oauth_cache_dir.as_deref(), + ) + .await + .map_err(|error| { + format_redacted_error( + "Databricks model discovery failed after sign-in", + &error, + &redaction_env, + ) + })? } Err(error) => { return Err(format_redacted_error( diff --git a/desktop/src-tauri/src/commands/project_repo_paths.rs b/desktop/src-tauri/src/commands/project_repo_paths.rs index 4193327c012..3fd4bbcaf82 100644 --- a/desktop/src-tauri/src/commands/project_repo_paths.rs +++ b/desktop/src-tauri/src/commands/project_repo_paths.rs @@ -145,13 +145,26 @@ pub(crate) fn find_local_repo_dir( } pub(crate) fn default_repos_root_candidates() -> Vec { + default_repos_root_candidates_for( + nest_dir(), + dirs::home_dir(), + crate::build_identity::is_demo_build(), + ) +} + +fn default_repos_root_candidates_for( + nest: Option, + home: Option, + is_demo_build: bool, +) -> Vec { let mut candidates = Vec::new(); - candidates.extend(nest_dir().map(|path| path.join("REPOS"))); - candidates.extend( - dirs::home_dir() - .map(|home| home.join(".buzz").join("REPOS")) - .filter(|path| !candidates.iter().any(|candidate| candidate == path)), - ); + candidates.extend(nest.map(|path| path.join("REPOS"))); + if !is_demo_build { + candidates.extend( + home.map(|home| home.join(".buzz").join("REPOS")) + .filter(|path| !candidates.iter().any(|candidate| candidate == path)), + ); + } candidates } @@ -190,3 +203,34 @@ pub(crate) fn canonical_repos_roots( } Ok(roots) } + +#[cfg(test)] +mod tests { + use super::default_repos_root_candidates_for; + use std::path::PathBuf; + + #[test] + fn production_keeps_the_legacy_repo_fallback() { + let home = PathBuf::from("/Users/example"); + assert_eq!( + default_repos_root_candidates_for( + Some(home.join(".buzz-dev")), + Some(home.clone()), + false, + ), + vec![home.join(".buzz-dev/REPOS"), home.join(".buzz/REPOS")] + ); + } + + #[test] + fn named_demos_only_search_their_selected_nest() { + let home = PathBuf::from("/Users/example"); + for slug in ["workstream-board", "second-demo"] { + let nest = home.join(format!(".buzz-demo-{slug}")); + assert_eq!( + default_repos_root_candidates_for(Some(nest.clone()), Some(home.clone()), true,), + vec![nest.join("REPOS")] + ); + } + } +} diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 83ac7e59ff9..614c62e1aaf 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -404,6 +404,18 @@ const ENTITY_LINK_TABS: [&str; 6] = [ "channels", ]; +/// Validate the build-specific transport URL, then hand the frontend its +/// canonical entity-link representation. Never broaden frontend scheme trust. +fn canonical_entity_deep_link(url: &Url, build_scheme: &str) -> Option { + if url.scheme() != build_scheme { + return None; + } + parse_entity_deep_link(url)?; + let mut canonical = url.clone(); + canonical.set_scheme("buzz").ok()?; + Some(canonical.into()) +} + /// The canonical-form rules match `parseEntityLink`: no path segments, no /// fragment, and no parameters beyond `owner`/`d` (plus `id` for event /// links and the optional `tab` for coordinate links), so a future @@ -600,7 +612,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } }; - if url.scheme() != "buzz" { + if url.scheme() != crate::build_identity::deep_link_scheme() { eprintln!("buzz-desktop: ignoring unsupported deep link scheme: {url_str}"); return; } @@ -678,17 +690,17 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let _ = app.emit("deep-link-message", payload); } Some("repo" | "project" | "pr" | "issue") => { - // `buzz://repo|project?owner=&d=` and - // `buzz://pr|issue?id=&owner=&d=` — the - // share links copied from the Projects UI. The frontend owns - // routing (`useEntityDeepLinks`), so the validated URL is - // forwarded unchanged. - if parse_entity_deep_link(&url).is_none() { + // OS routing uses this build's scheme; frontend navigation consumes + // canonical buzz:// entity links rather than transport identity. + let Some(href) = canonical_entity_deep_link( + &url, + crate::build_identity::deep_link_scheme().as_ref(), + ) else { eprintln!("buzz-desktop: malformed entity deep link: {url_str}"); return; - } + }; activate_main_window(app); - let pending = queue_entity_deep_link(app, url_str.to_owned()); + let pending = queue_entity_deep_link(app, href); let _ = app.emit("deep-link-entity", pending); } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { diff --git a/desktop/src-tauri/src/deep_link_tests.rs b/desktop/src-tauri/src/deep_link_tests.rs index 84a08c4c64e..da960f3a2d9 100644 --- a/desktop/src-tauri/src/deep_link_tests.rs +++ b/desktop/src-tauri/src/deep_link_tests.rs @@ -1,10 +1,11 @@ use url::Url; use super::{ - parse_add_community_deep_link, parse_channel_deep_link, parse_entity_deep_link, - parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link, - PendingCommunityDeepLink, PendingCommunityDeepLinks, PendingEntityDeepLinks, - PendingNavigationDeepLink, PendingNavigationDeepLinks, ENTITY_LINK_TABS, + canonical_entity_deep_link, parse_add_community_deep_link, parse_channel_deep_link, + parse_entity_deep_link, parse_join_deep_link, parse_message_deep_link, + parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, + PendingEntityDeepLinks, PendingNavigationDeepLink, PendingNavigationDeepLinks, + ENTITY_LINK_TABS, }; fn entity_link_golden() -> serde_json::Value { @@ -12,6 +13,35 @@ fn entity_link_golden() -> serde_json::Value { .expect("valid entity-links golden fixture") } +#[test] +fn demo_entity_transport_produces_the_frontend_golden_contract() { + let golden = entity_link_golden(); + let scheme = "buzz-demo-board-1234567812345678"; + for canonical in golden["links"].as_object().unwrap().values() { + let canonical = canonical.as_str().unwrap(); + let transport = Url::parse(&canonical.replacen("buzz:", &format!("{scheme}:"), 1)).unwrap(); + let href = canonical_entity_deep_link(&transport, scheme).unwrap(); + // This same fixture is parsed and routed by the frontend entity tests. + assert_eq!(href, canonical); + let queue = PendingEntityDeepLinks::default(); + let pending = queue.enqueue(href); + assert_eq!(queue.first().unwrap().href, canonical); + assert!(queue.acknowledge(&pending.id)); + assert!(queue.first().is_none()); + assert!(canonical_entity_deep_link(&transport, "buzz").is_none()); + assert!( + canonical_entity_deep_link(&transport, "buzz-demo-other-8765432187654321").is_none() + ); + assert!(canonical_entity_deep_link(&Url::parse(canonical).unwrap(), scheme).is_none()); + assert_eq!( + canonical_entity_deep_link(&Url::parse(canonical).unwrap(), "buzz").as_deref(), + Some(canonical) + ); + } + let invalid = Url::parse(&format!("{scheme}://repo?owner=bad&d=repo")).unwrap(); + assert!(canonical_entity_deep_link(&invalid, scheme).is_none()); +} + #[test] fn parse_entity_deep_link_accepts_every_share_link_shape() { let golden = entity_link_golden(); diff --git a/desktop/src-tauri/src/huddle/models.rs b/desktop/src-tauri/src/huddle/models.rs index f9f70657698..09154d5237d 100644 --- a/desktop/src-tauri/src/huddle/models.rs +++ b/desktop/src-tauri/src/huddle/models.rs @@ -587,6 +587,10 @@ fn tts_model_slot() -> ModelSlot { .with_expected_sizes(tts_expected_size) } +fn models_dir(nest_dir: PathBuf) -> PathBuf { + nest_dir.join("models") +} + // ── ModelManager ────────────────────────────────────────────────────────────── /// Manages download and location of STT/TTS model files. @@ -594,18 +598,18 @@ fn tts_model_slot() -> ModelSlot { /// Cheap to clone — all inner state is behind `Arc`. #[derive(Clone)] pub struct ModelManager { - /// `~/.buzz/models/` + /// Model storage under the selected build's nest. models_dir: PathBuf, stt: ModelSlot, tts: ModelSlot, } impl ModelManager { - /// Create a new `ModelManager` rooted at `~/.buzz/models/`. + /// Create a new `ModelManager` rooted in the selected build's nest. /// - /// Returns `None` if the home directory cannot be resolved. + /// Returns `None` if the nest directory cannot be resolved. pub fn new() -> Option { - let models_dir = dirs::home_dir()?.join(".buzz").join("models"); + let models_dir = models_dir(crate::managed_agents::nest_dir()?); let manager = Self { models_dir, stt: ModelSlot::new(STT_MODEL_DIR_NAME, STT_EXPECTED_FILES, STT_MODEL_VERSION), diff --git a/desktop/src-tauri/src/huddle/models_tests.rs b/desktop/src-tauri/src/huddle/models_tests.rs index 699ffbe459f..5f70b1f3f3a 100644 --- a/desktop/src-tauri/src/huddle/models_tests.rs +++ b/desktop/src-tauri/src/huddle/models_tests.rs @@ -1,5 +1,18 @@ use super::*; +#[test] +fn voice_models_follow_the_selected_build_nest() { + let home = PathBuf::from("/Users/example"); + for nest_name in [ + ".buzz", + ".buzz-demo-workstream-board", + ".buzz-demo-second-demo", + ] { + let nest = home.join(nest_name); + assert_eq!(models_dir(nest.clone()), nest.join("models")); + } +} + fn create_ready_model_dir(root: &Path) -> PathBuf { let model_dir = root.join(TTS_MODEL_DIR_NAME); std::fs::create_dir_all(&model_dir).expect("create model dir"); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f2b196c41d9..cd47c040588 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod app_menu; mod app_state; mod archive; +mod build_identity; mod builderlab; mod channel_head_cache; mod commands; @@ -126,7 +127,7 @@ pub fn run() { } // Forward any deep link URLs from the duplicate launch. for arg in &argv { - if arg.starts_with("buzz://") { + if crate::build_identity::is_deep_link_for_build(arg) { handle_deep_link_url(app, arg); } } @@ -396,7 +397,10 @@ pub fn run() { // the now-inert ~/.sprout; the frontend dedupes the toast. // Suppressed when a reset completed this boot: the nest was wiped and // a fresh ~/.sprout-less state is exactly what we want. - if !reset_outcome.completed && migration::migrate_legacy_nest() { + if !crate::build_identity::is_demo_build() + && !reset_outcome.completed + && migration::migrate_legacy_nest() + { let _ = app_handle.emit("legacy-nest-migrated", ()); } diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 5f375e23c1c..46f36212cea 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -63,12 +63,6 @@ const CANONICAL_SKILL_DIR: &str = ".agents/skills/buzz-cli"; /// Nest directory name for production builds. const NEST_DIR_PROD: &str = ".buzz"; -/// Nest directory name for dev builds. Dev builds (those whose Tauri app-data -/// directory name starts with `"xyz.block.buzz.app.dev"`) use a separate nest -/// so that the DMG and dev-build instances don't clobber each other's -/// `.repos-dir` dotfile and `REPOS` symlink. -const NEST_DIR_DEV: &str = ".buzz-dev"; - /// Process-lifetime nest directory. Initialized once at startup via /// [`init_nest_dir`] before any call to [`nest_dir`]. /// @@ -88,8 +82,8 @@ static NEST_DIR: std::sync::OnceLock> = std::sync::OnceLock::new /// when the Tauri app-data directory name starts with `"xyz.block.buzz.app.dev"`. /// Pass `false` for production (signed DMG) builds. pub fn init_nest_dir(is_dev: bool) { - let suffix = if is_dev { NEST_DIR_DEV } else { NEST_DIR_PROD }; - let path = dirs::home_dir().map(|h| h.join(suffix)); + let suffix = crate::build_identity::nest_name(is_dev); + let path = dirs::home_dir().map(|h| h.join(suffix.as_ref())); // set() is a no-op when already initialized, which is correct: only the // first call (at boot, before any filesystem work) should win. let _ = NEST_DIR.set(path); @@ -315,12 +309,8 @@ fn ensure_skill_symlinks(_root: &Path) -> Result<(), String> { /// Dev builds (`is_dev = true`) use `"buzz-dev"` so that a running DMG and a /// concurrent dev build each own a separate link and never clobber each other — /// the same isolation that separates `~/.buzz` (prod) from `~/.buzz-dev` (dev). -pub fn cli_link_name(is_dev: bool) -> &'static str { - if is_dev { - "buzz-dev" - } else { - "buzz" - } +pub fn cli_link_name(is_dev: bool) -> String { + crate::build_identity::cli_name(is_dev) } /// Ensures `~/.local/bin/buzz` (prod) or `~/.local/bin/buzz-dev` (dev) is a diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 9aa1eeb0985..7d54c5a7b07 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -7,7 +7,7 @@ fn nest_dir_is_under_home() { // whether init_nest_dir was called before this test ran. let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or(""); assert!( - name == NEST_DIR_PROD || name == NEST_DIR_DEV, + name == NEST_DIR_PROD || name == crate::build_identity::nest_name(true), "nest_dir must end with .buzz or .buzz-dev, got {dir:?}" ); } @@ -23,7 +23,7 @@ fn init_nest_dir_prod_sets_buzz() { if let Some(d) = dir { let name = d.file_name().and_then(|n| n.to_str()).unwrap_or(""); assert!( - name == NEST_DIR_PROD || name == NEST_DIR_DEV, + name == NEST_DIR_PROD || name == crate::build_identity::nest_name(true), "nest_dir suffix must be .buzz or .buzz-dev, got {d:?}" ); } @@ -357,13 +357,19 @@ fn ensure_skill_symlinks_skip_dangling_symlink() { } #[test] -fn cli_link_name_prod_is_buzz() { - assert_eq!(cli_link_name(false), "buzz"); +fn cli_link_name_prod_follows_build_identity() { + let expected = crate::build_identity::demo_slug() + .map(|slug| format!("buzz-demo-{slug}")) + .unwrap_or_else(|| "buzz".to_string()); + assert_eq!(cli_link_name(false), expected); } #[test] -fn cli_link_name_dev_is_buzz_dev() { - assert_eq!(cli_link_name(true), "buzz-dev"); +fn cli_link_name_dev_follows_build_identity() { + let expected = crate::build_identity::demo_slug() + .map(|slug| format!("buzz-demo-{slug}")) + .unwrap_or_else(|| "buzz-dev".to_string()); + assert_eq!(cli_link_name(true), expected); } #[cfg(unix)] @@ -395,8 +401,8 @@ fn ensure_cli_symlink_creates_symlink_dev() { let local_bin = tmp.path().join("local_bin"); fs::create_dir_all(&local_bin).unwrap(); - // Dev link must be "buzz-dev", never "buzz". - assert_eq!(cli_link_name(true), "buzz-dev"); + // Dev and demo links must never overwrite production's "buzz". + assert_ne!(cli_link_name(true), "buzz"); let link = local_bin.join(cli_link_name(true)); std::os::unix::fs::symlink(exe_parent.join("buzz"), &link).unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index afaaa2b4eb3..e6570482afa 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -67,6 +67,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // ambient env var must not be able to forge setup mode (NotReady) on a // Ready agent or suppress it (empty/stale payload) on a NotReady one. "BUZZ_ACP_SETUP_PAYLOAD", + // Demo-build identity owns the child agent config root. A user override + // could silently reconnect a demo harness to production OAuth state. + "BUZZ_AGENT_CONFIG_DIR", // Desktop ownership markers: these brand every spawned harness with the // launching Desktop instance. A user-supplied override would let a // definition masquerade as a different instance or fake the nonce used diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 0ce5ca7b219..f6bb758fbe5 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -501,7 +501,6 @@ pub fn spawn_agent_child( // The caller supplies the explicit canonical pair relay. This is the only // relay this child may connect to, regardless of the record/workspace default. let effective_relay_url = runtime_key.relay_url.clone(); - // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink // - nvm-managed node/npm (nvm initializes only in interactive shells) @@ -809,6 +808,7 @@ pub fn spawn_agent_child( for (key, value) in &descriptor.env { command.env(key, value); } + crate::build_identity::apply_demo_config_home(&mut command)?; // B5: carry persisted effort; harness resolves thought_level configId at first session. // Written AFTER descriptor.env so the canonical persisted value wins over any diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 1e22d7aaeca..9b105e94d4f 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -129,10 +129,9 @@ pub fn run_boot_migrations_after_reset(app: &tauri::AppHandle) { } fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { - // Initialize the process-lifetime nest directory before any filesystem - // operation that calls nest_dir(). The discriminator matches the existing - // pattern used by reconcile_target_dir: dev instances have an app-data-dir - // name starting with CANONICAL_DEV_IDENTIFIER. + // Initialize the process-lifetime nest directory before filesystem access + // that calls nest_dir(). The discriminator matches reconcile_target_dir: + // dev instances have an app-data-dir name starting with CANONICAL_DEV_IDENTIFIER. let is_dev = if let Ok(data_dir) = app.path().app_data_dir() { let dev = data_dir .file_name() @@ -144,18 +143,18 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { false }; - // On dev builds, copy `.repos-dir` from ~/.buzz → ~/.buzz-dev BEFORE - // control returns to lib.rs where resolve_repos_at_boot() reads it. This - // ensures the dev nest boots with the correct workspace on its first launch, - // matching what the prod nest had configured. Skip-if-dest-exists so it is - // idempotent and never clobbers a value the dev nest already set explicitly. - // Uses the composed helper so gate + migration share the tested code path. + // On dev builds, copy `.repos-dir` from ~/.buzz → ~/.buzz-dev before + // resolve_repos_at_boot() reads it. Skip-if-dest-exists so it is idempotent + // and never clobbers a value the dev nest already set explicitly. + // The composed helper keeps gate + migration on the tested code path. if let (Some(home), Some(dev_nest)) = (dirs::home_dir(), crate::managed_agents::nest_dir()) { maybe_migrate_dev_repos_dir(is_dev, reset_completed, &home, &dev_nest); } - migrate_legacy_app_data_dir(app); - sync_shared_agent_data(app); + if !crate::build_identity::is_demo_build() { + migrate_legacy_app_data_dir(app); + sync_shared_agent_data(app); + } // Dev-build-only: copy any agent keys that exist in the production // keyring ("buzz-desktop") into the dev service ("buzz-desktop-dev") // so existing agents don't lose their keys after the service-name split. diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index 18ddd80eb8d..401d63c9c49 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -104,6 +104,11 @@ pub(crate) struct ResetContext<'a> { pub keychain: &'a dyn ResetKeychain, pub home_dir: Option, pub is_dev: bool, + /// Build-owned config root for demos. Production leaves this unset. + pub demo_config_dir: Option, + /// Demo builds own only build-scoped state and must never delete shared + /// production or legacy agent roots. + pub is_demo: bool, } /// Entry point called from `lib.rs` setup (before migrations). @@ -126,6 +131,16 @@ pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome { let legacy_dir = crate::migration::legacy_app_data_dir(app_data_dir); let nest_dir = crate::managed_agents::nest_dir(); + let demo_config_dir = match crate::build_identity::demo_config_home() { + Ok(dir) => dir, + Err(error) => { + eprintln!("buzz-desktop reset: {error}"); + return ResetOutcome { + completed: false, + failed: true, + }; + } + }; let ctx = ResetContext { app_data_dir, legacy_app_data_dir: legacy_dir, @@ -133,6 +148,8 @@ pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome { keychain: &store, home_dir, is_dev, + demo_config_dir, + is_demo: crate::build_identity::is_demo_build(), }; run_boot_reset_with_keychain(ctx) @@ -166,6 +183,15 @@ fn rename_to_trash(src: &Path) -> Result { /// Core wipe logic — separated for testing. pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcome { + // An unknown demo credential root is not evidence of an absent root. Refuse + // before any destructive work and retain reset intent for the next boot. + if ctx.is_demo && ctx.demo_config_dir.is_none() { + eprintln!("buzz-desktop reset: cannot resolve demo credential directory"); + return ResetOutcome { + completed: false, + failed: true, + }; + } let app_data_dir = ctx.app_data_dir; // ── Step 1: rename app-data dir (atomic — sentinel survives the parent) ── @@ -211,13 +237,34 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom None }; - // ── Step 3: remove nest, ~/.sprout, ~/.config/buzz-agent, CLI symlink ──── + // ── Step 3: remove build-owned nest and CLI symlink ────────────────────── + // Production and dev preserve their existing legacy/global cleanup. A demo + // never owns these shared roots, so signing out of one must leave them + // available to production and every other demo. if let Some(ref nest) = ctx.nest_dir { let _ = std::fs::remove_dir_all(nest); } + // A demo owns credentials here. Failure to remove them must keep the reset + // pending, even if the app data and keychain were successfully wiped. + let demo_config_removed = + ctx.demo_config_dir + .as_ref() + .is_none_or(|path| match std::fs::remove_dir_all(path) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => { + eprintln!( + "buzz-desktop reset: remove demo config {}: {error}", + path.display() + ); + false + } + }); if let Some(ref home) = ctx.home_dir { - let _ = std::fs::remove_dir_all(home.join(".sprout")); - let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent")); + if !ctx.is_demo { + let _ = std::fs::remove_dir_all(home.join(".sprout")); + let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent")); + } let link_name = crate::managed_agents::cli_link_name(ctx.is_dev); let _ = std::fs::remove_file(home.join(".local").join("bin").join(link_name)); } @@ -273,6 +320,11 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom .map(|p| !p.exists()) .unwrap_or(true); let nest_gone = ctx.nest_dir.as_ref().map(|n| !n.exists()).unwrap_or(true); + // `exists()` treats metadata errors as absence. Only NotFound establishes + // that credentials are gone; a dangling symlink is not an absent root. + let demo_config_gone = ctx.demo_config_dir.as_ref().is_none_or(|path| { + matches!(std::fs::symlink_metadata(path), Err(error) if error.kind() == std::io::ErrorKind::NotFound) + }); let trash_app_gone = !trash_app.exists(); let trash_legacy_gone = trash_legacy.as_ref().map(|p| !p.exists()).unwrap_or(true); let trash_webkit_gone = trash_webkit.as_ref().map(|p| !p.exists()).unwrap_or(true); @@ -281,6 +333,8 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom || !app_data_gone || !legacy_gone || !nest_gone + || !demo_config_removed + || !demo_config_gone || !trash_app_gone || !trash_legacy_gone || !trash_webkit_gone @@ -288,6 +342,7 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom eprintln!( "buzz-desktop reset: verification failed (keychain_wiped={keychain_ok}, \ app_data_gone={app_data_gone}, legacy_gone={legacy_gone}, nest_gone={nest_gone}, \ + demo_config_removed={demo_config_removed}, demo_config_gone={demo_config_gone}, \ trash_app_gone={trash_app_gone}, trash_legacy_gone={trash_legacy_gone}, \ trash_webkit_gone={trash_webkit_gone})" ); @@ -318,6 +373,10 @@ mod tests { use std::cell::Cell; use tempfile::TempDir; + mod demo { + include!("reset_demo_tests.rs"); + } + // ── Fake keychain ───────────────────────────────────────────────────────── struct FakeKeychain { @@ -408,6 +467,8 @@ mod tests { keychain, home_dir: None, // skip nest/sprout/CLI ops in unit tests is_dev, + demo_config_dir: None, + is_demo: false, } } @@ -451,6 +512,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: false, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -584,6 +647,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: true, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -620,6 +685,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: false, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -653,6 +720,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: false, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -736,6 +805,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: true, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); assert!(outcome.completed, "reset must complete"); @@ -830,6 +901,8 @@ mod tests { keychain: &kc1, home_dir: Some(tmp.path().to_path_buf()), is_dev: false, + demo_config_dir: None, + is_demo: false, }; let first = run_boot_reset_with_keychain(ctx1); assert!(first.failed, "first attempt must fail"); @@ -853,6 +926,8 @@ mod tests { keychain: &kc2, home_dir: Some(tmp.path().to_path_buf()), is_dev: false, + demo_config_dir: None, + is_demo: false, }; let second = run_boot_reset_with_keychain(ctx2); assert!(second.completed, "second attempt must complete"); diff --git a/desktop/src-tauri/src/reset_demo_tests.rs b/desktop/src-tauri/src/reset_demo_tests.rs new file mode 100644 index 00000000000..9db2ab3dc74 --- /dev/null +++ b/desktop/src-tauri/src/reset_demo_tests.rs @@ -0,0 +1,169 @@ +use super::*; + +#[test] +fn test_demo_reset_preserves_shared_and_other_build_state() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let app_data = tmp + .path() + .join("Application Support") + .join("xyz.block.buzz.app.demo.current-1234567812345678"); + let demo_nest = home.join(".buzz-demo-current-1234567812345678"); + let prod_nest = home.join(".buzz"); + let other_demo_nest = home.join(".buzz-demo-other-8765432187654321"); + let shared_sprout = home.join(".sprout"); + let shared_agent = home.join(".config").join("buzz-agent"); + let demo_config = home + .join("Library") + .join("Application Support") + .join("buzz-demo-current-1234567812345678"); + let demo_oauth = demo_config.join("buzz-agent").join("oauth"); + let other_demo_config = home + .join("Library") + .join("Application Support") + .join("buzz-demo-other-8765432187654321"); + let other_demo_oauth = other_demo_config.join("buzz-agent").join("oauth"); + + for path in [ + &app_data, + &demo_nest, + &prod_nest, + &other_demo_nest, + &shared_sprout, + &shared_agent, + &demo_oauth, + &other_demo_oauth, + ] { + std::fs::create_dir_all(path).unwrap(); + } + write_sentinel(&app_data).unwrap(); + + let kc = FakeKeychain::ok(); + let ctx = ResetContext { + app_data_dir: &app_data, + legacy_app_data_dir: None, + nest_dir: Some(demo_nest.clone()), + keychain: &kc, + home_dir: Some(home), + is_dev: false, + demo_config_dir: Some(demo_config.clone()), + is_demo: true, + }; + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(outcome.completed, "demo reset must complete"); + assert!(!app_data.exists(), "demo app data must be wiped"); + assert!(!demo_nest.exists(), "selected demo nest must be wiped"); + assert!( + !demo_config.exists(), + "selected demo auth root must be wiped" + ); + assert!( + other_demo_oauth.exists(), + "another demo's concrete auth root must survive" + ); + assert!(prod_nest.exists(), "production nest must survive"); + assert!(other_demo_nest.exists(), "another demo nest must survive"); + assert!(shared_sprout.exists(), "shared legacy state must survive"); + assert!( + shared_agent.exists(), + "shared agent auth state must survive" + ); +} + +#[test] +fn demo_config_delete_failure_keeps_sentinel_until_retry() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let config = tmp.path().join("demo-config"); + let production = tmp.path().join("production/oauth/token.json"); + let sibling = tmp.path().join("sibling/oauth/token.json"); + for path in [&production, &sibling] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "preserve").unwrap(); + } + // A file at the directory path makes remove_dir_all fail on every platform, + // independent of the test user's privileges. + std::fs::write(&config, "obstruction").unwrap(); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let run = || { + let mut ctx = make_ctx(&app_data, &kc, false); + ctx.is_demo = true; + ctx.demo_config_dir = Some(config.clone()); + run_boot_reset_with_keychain(ctx) + }; + let first = run(); + assert!(first.failed && !first.completed); + assert!(check_sentinel(&app_data)); + assert!(config.exists()); + + std::fs::remove_file(&config).unwrap(); + let token = config.join("buzz-agent/oauth/databricks/token.json"); + std::fs::create_dir_all(token.parent().unwrap()).unwrap(); + std::fs::write(&token, "demo credential").unwrap(); + let second = run(); + assert!(second.completed && !second.failed); + assert!(!check_sentinel(&app_data)); + assert!(!config.exists()); + for path in [&production, &sibling] { + assert_eq!(std::fs::read_to_string(path).unwrap(), "preserve"); + } + // A retry after a crash that already removed the root must also succeed. + write_sentinel(&app_data).unwrap(); + assert!(run().completed); + assert!(!check_sentinel(&app_data)); +} + +#[cfg(unix)] +#[test] +fn demo_oauth_permission_failure_preserves_retry_intent() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let config = tmp.path().join("demo-config"); + let oauth = config.join("buzz-agent/oauth/databricks"); + let token = oauth.join("token.json"); + std::fs::create_dir_all(&oauth).unwrap(); + std::fs::write(&token, "demo credential").unwrap(); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let run = || { + let mut ctx = make_ctx(&app_data, &kc, false); + ctx.is_demo = true; + ctx.demo_config_dir = Some(config.clone()); + run_boot_reset_with_keychain(ctx) + }; + std::fs::set_permissions(&oauth, std::fs::Permissions::from_mode(0o500)).unwrap(); + let first = run(); + // Restore permissions before assertions so a failure never leaves test debris. + if oauth.exists() { + std::fs::set_permissions(&oauth, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + assert!(first.failed && !first.completed); + assert!(check_sentinel(&app_data)); + assert_eq!(std::fs::read_to_string(&token).unwrap(), "demo credential"); + assert!(run().completed); + assert!(!config.exists()); + assert!(!check_sentinel(&app_data)); +} + +#[test] +fn unresolved_demo_config_keeps_reset_pending_without_deleting_state() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let mut ctx = make_ctx(&app_data, &kc, false); + ctx.is_demo = true; + assert!(ctx.demo_config_dir.is_none()); + let outcome = run_boot_reset_with_keychain(ctx); + assert!(outcome.failed && !outcome.completed); + assert!(check_sentinel(&app_data)); + assert!( + app_data.exists(), + "unresolved root must refuse before wiping" + ); +} diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index b6cad59567a..59087861be1 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { afterEach, test } from "node:test"; const ipcHandlers = new Map(); @@ -23,8 +24,11 @@ globalThis.window = { }; globalThis.__TAURI_INTERNALS__ = tauriInternals; -const { listenForNavigationDeepLinks, resetNavigationDeepLinkDrain } = - await import("@/shared/deep-link.ts"); +const { + listenForEntityDeepLinks, + listenForNavigationDeepLinks, + resetNavigationDeepLinkDrain, +} = await import("@/shared/deep-link.ts"); function deferred() { let resolve; @@ -449,3 +453,58 @@ test("rejected navigation remains queued and is not acknowledged", async () => { console.warn = originalWarn; } }); + +for (const delivery of ["cold-start", "running-instance"]) { + test(`canonical native demo entity payloads navigate before acknowledgement: ${delivery}`, async () => { + // Rust's canonical_entity_deep_link test proves all demo transport URLs + // produce these exact shared values, rather than passing demo schemes on. + const golden = JSON.parse( + readFileSync( + new URL("../../../test-fixtures/entity-links.json", import.meta.url), + "utf8", + ), + ); + const { parseEntityLink } = await import("@/shared/lib/entityLink.ts"); + const pending = Object.entries(golden.links).map(([id, href]) => ({ + id, + href, + })); + const queue = delivery === "cold-start" ? [...pending] : []; + const opened = []; + const acknowledged = []; + let notify; + ipcHandlers.set("plugin:event|listen", ({ handler }) => { + notify = callbacks.get(handler); + return handler; + }); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("take_pending_entity_deep_link", () => queue[0] ?? null); + ipcHandlers.set("acknowledge_pending_entity_deep_link", ({ id }) => { + assert.equal(queue[0]?.id, id); + assert.equal(opened.length, acknowledged.length + 1); + acknowledged.push(id); + queue.shift(); + return true; + }); + const unlisten = await listenForEntityDeepLinks((href) => { + const parsed = parseEntityLink(href); + assert.equal(parsed.ok, true, href); + opened.push(parsed.value); + return true; + }); + await settle(); + if (delivery === "running-instance") { + queue.push(...pending); + notify({ payload: pending[0] }); + } + await settle(); + await settle(); + assert.deepEqual( + acknowledged, + pending.map(({ id }) => id), + ); + assert.equal(opened.length, pending.length); + assert.equal(queue.length, 0); + unlisten(); + }); +}