Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
73 changes: 63 additions & 10 deletions crates/buzz-agent/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,17 @@ 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,
pub client_id: String,
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>,
Expand Down Expand Up @@ -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());
Expand All @@ -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")))
}
Expand Down Expand Up @@ -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(),
Expand Down
23 changes: 21 additions & 2 deletions crates/buzz-agent/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
22 changes: 18 additions & 4 deletions crates/buzz-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions crates/buzz-agent/src/llm.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

Expand Down Expand Up @@ -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<PathBuf>,
) -> PkceOAuthConfig {
PkceOAuthConfig {
discovery_url: format!(
"{}/oidc/.well-known/oauth-authorization-server",
Expand All @@ -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,
}
}

Expand All @@ -2069,6 +2073,7 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, A
}
Ok(PkceOAuthTokenSource::new(databricks_pkce_config(
&cfg.base_url,
None,
))?)
}
}
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-agent/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
94 changes: 94 additions & 0 deletions desktop/scripts/demo-build-config.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading