mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): add isolated named demo builds (#6407)
🤖 I’m Larry, updating this description on Logan’s behalf. ## Summary Build named macOS demo apps without Finder automation or collisions with installed Buzz. `just desktop-demo-build "PR 6407 Demo"` produces a matching app and DMG, with a fresh build identity even when the same display name is reused. - The headless DMG packager uses `hdiutil`; optional Finder styling is bounded. The existing production release recipe is unchanged. - Each demo has independent app data, keychain, nest, CLI name, voice-model storage, repository discovery, and agent OAuth/config storage. Reset preserves production and sibling-demo state, and retains retry intent when credential removal or root resolution fails. - Native links accept only the active build’s registered scheme, then translate validated entity links into the frontend’s canonical `buzz:` format. - The recipe builds all six executable sidecars. Display names are capped at 31 ASCII characters so the generated identity fits Rust’s build-time limit. **Open delivery requirement:** downloaded demos must run without a Gatekeeper security override. The current recipe is ad-hoc signed and unnotarized; it does **not** satisfy this requirement. Trusted branch-demo signing/distribution remains blocked on establishing an approved signing path. This PR is not being presented as complete download-and-run delivery. ### Related issue N/A — reported in the Buzz DMG-packaging workstream. ### Testing At `11ce21ff97cb387ad676e7caa65b00964097d0bb`, macOS Blox passed the Tauri workspace suite and compiled-flags gate (including the full named-demo state; each library pass: 2,992 passed, 19 ignored), Tauri all-target clippy, the full `buzz-agent` package suite, and frontend lint/typecheck plus 5,733 tests. Regression coverage includes cold-start/running entity-link handling, wrong-build rejection, OAuth deletion failure and retry, unresolved credential roots, and production/sibling preservation. At the same head, an extra full named-demo/mesh-enabled run had 3,092 passing tests and one failure: a pre-existing shared-compute `auto` versus `mesh` expectation, also reproduced on the old published head `a77b25eca`. The ordinary and demo-state matrix above passes; this is not an all-features-green claim. Live macOS Launch Services delivery remains unverified. GitHub CI completed with 30 successful checks and 9 skipped. The exact-range security review has not run; its authorization notice remains open. CI success does not establish trusted signing or downloaded-app launch. Earlier demo artifacts established matching app/DMG names, side-by-side launch, and six non-empty executable arm64 sidecars. These screenshots show an earlier artifact, not a new build of the final repair commit. Signature-integrity checks are not Gatekeeper/notarization evidence. <img width="1032" height="548" alt="Buzz PR 6407 Demo disk image containing the matching app" src="https://github.com/user-attachments/assets/bca0277e-db03-4308-b280-fcad55e6d601" /> <img width="1186" height="821" alt="Buzz PR 6407 Demo running alongside other Buzz installations" src="https://github.com/user-attachments/assets/b4bf4ae5-c341-4e15-8090-9d2ea7c623b6" /> --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: Other Brother Darryl <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz> Co-authored-by: Larry <loganj+sandbox-larry@squareup.com> Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
This commit is contained in:
45
Justfile
45
Justfile
@@ -256,7 +256,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.
|
||||
@@ -277,6 +288,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
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
pub cache_namespace: String,
|
||||
/// When `Some`, the engine writes tokens here instead of
|
||||
/// `~/.config/buzz-agent/oauth/<cache_namespace>/`. Production code
|
||||
/// `<platform config dir>/buzz-agent/oauth/<cache_namespace>/`. 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<PathBuf>,
|
||||
@@ -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<PathBuf>,
|
||||
home_dir: Option<PathBuf>,
|
||||
) -> Result<PathBuf, AgentError> {
|
||||
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<PathBuf, AgentError> {
|
||||
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<PathBuf, AgentError> {
|
||||
let mut h = sha2::Sha256::new();
|
||||
h.update(cfg.discovery_url.as_bytes());
|
||||
@@ -455,12 +479,7 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result<PathBuf, AgentError> {
|
||||
|
||||
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(),
|
||||
|
||||
@@ -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<Vec<ModelEntry>, 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<Vec<ModelEntry>, 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(
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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 <provider>` — run the interactive auth flow for a
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -2033,7 +2034,10 @@ where
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig {
|
||||
pub(crate) fn databricks_pkce_config(
|
||||
host: &str,
|
||||
cache_dir_override: Option<PathBuf>,
|
||||
) -> PkceOAuthConfig {
|
||||
PkceOAuthConfig {
|
||||
discovery_url: format!(
|
||||
"{}/oidc/.well-known/oauth-authorization-server",
|
||||
@@ -2045,7 +2049,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2070,6 +2074,7 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, A
|
||||
}
|
||||
Ok(PkceOAuthTokenSource::new(databricks_pkce_config(
|
||||
&cfg.base_url,
|
||||
None,
|
||||
))?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "node ./scripts/tauri-command.mjs",
|
||||
"test:e2e": "pnpm build:e2e && playwright test",
|
||||
|
||||
94
desktop/scripts/demo-build-config.mjs
Normal file
94
desktop/scripts/demo-build-config.mjs
Normal file
@@ -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 <demo-name> <output-config-path>",
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
134
desktop/scripts/demo-build-config.test.mjs
Normal file
134
desktop/scripts/demo-build-config.test.mjs
Normal file
@@ -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")));
|
||||
}
|
||||
128
desktop/scripts/package-macos-dmg.sh
Executable file
128
desktop/scripts/package-macos-dmg.sh
Executable file
@@ -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 <Buzz.app> <output.dmg>" >&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"
|
||||
@@ -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() {
|
||||
|
||||
@@ -7,7 +7,12 @@ fn dev_keyring_service(configured: Option<String>) -> 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<String> = 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<String> = std::sync::OnceLock::new();
|
||||
DEV_SERVICE
|
||||
.get_or_init(|| dev_keyring_service(std::env::var("BUZZ_DEV_KEYRING_SERVICE").ok()))
|
||||
|
||||
183
desktop/src-tauri/src/build_identity.rs
Normal file
183
desktop/src-tauri/src/build_identity.rs
Normal file
@@ -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<Option<std::path::PathBuf>, String> {
|
||||
demo_config_home_for(demo_slug(), dirs::config_dir())
|
||||
}
|
||||
|
||||
pub(crate) fn demo_agent_oauth_cache_dir() -> Result<Option<std::path::PathBuf>, 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<std::path::PathBuf>,
|
||||
) -> Result<Option<std::path::PathBuf>, 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -145,13 +145,26 @@ pub(crate) fn find_local_repo_dir(
|
||||
}
|
||||
|
||||
pub(crate) fn default_repos_root_candidates() -> Vec<std::path::PathBuf> {
|
||||
default_repos_root_candidates_for(
|
||||
nest_dir(),
|
||||
dirs::home_dir(),
|
||||
crate::build_identity::is_demo_build(),
|
||||
)
|
||||
}
|
||||
|
||||
fn default_repos_root_candidates_for(
|
||||
nest: Option<std::path::PathBuf>,
|
||||
home: Option<std::path::PathBuf>,
|
||||
is_demo_build: bool,
|
||||
) -> Vec<std::path::PathBuf> {
|
||||
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")]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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=<pubkey>&d=<dtag>` and
|
||||
// `buzz://pr|issue?id=<eventId>&owner=<pubkey>&d=<dtag>` — 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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Self> {
|
||||
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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
mod app_menu;
|
||||
mod app_state;
|
||||
mod archive;
|
||||
mod build_identity;
|
||||
mod builderlab;
|
||||
mod channel_head_cache;
|
||||
mod commands;
|
||||
@@ -127,7 +128,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);
|
||||
}
|
||||
}
|
||||
@@ -397,7 +398,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", ());
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Option<PathBuf>> = 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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -104,6 +104,11 @@ pub(crate) struct ResetContext<'a> {
|
||||
pub keychain: &'a dyn ResetKeychain,
|
||||
pub home_dir: Option<PathBuf>,
|
||||
pub is_dev: bool,
|
||||
/// Build-owned config root for demos. Production leaves this unset.
|
||||
pub demo_config_dir: Option<PathBuf>,
|
||||
/// 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<PathBuf, String> {
|
||||
|
||||
/// 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");
|
||||
|
||||
169
desktop/src-tauri/src/reset_demo_tests.rs
Normal file
169
desktop/src-tauri/src/reset_demo_tests.rs
Normal file
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user