Files
buzz/desktop/src-tauri/src/commands/workspace.rs
16cc3de6d6 fix(desktop): enforce owner-only access in internal builds (#4053)
## Problem

Managed agents in internal Buzz builds should answer only their owner.
Previously, an agent could keep a broader access setting and respond to
other people, which did not match the access policy for internal builds.

This PR makes owner-only access effective for every managed agent in
internal builds and makes that restriction clear in the Desktop UI. Open
source builds remain configurable.

## Changes

- Enforce owner-only access when any managed agent starts or is deployed
from an internal build.
- Show the agent access control as locked to **Only me** in Desktop,
with an explanation of why it cannot be changed.
- Keep Welcome teammates working under the same rule without triggering
unnecessary restarts.
- Leave open source build behavior unchanged. This changes effective
runtime access without rewriting stored or relay-advertised settings.

The companion [#4064](https://github.com/block/buzz/pull/4064) explains
the restriction in-thread when someone without access mentions an agent.

The enforcement will remain inactive in shipped builds until
[squareup/buzz-releases#74](https://github.com/squareup/buzz-releases/pull/74)
marks internal releases during the build.

## Screenshots

| Before | After |
| --- | --- |
| ![Editable agent access control before the
change](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4053/4053-before.png)
| ![Agent access locked to Only me in an internal
build](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4053/4053-after-v2.png)
|

## Tests

Added coverage for:

- Runtime enforcement for [locally run
agents](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/runtime/tests.rs#L196)
and [deployed
agents](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/commands/agents_tests.rs#L510).
- The [current-build deployment
path](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/commands/agents_tests.rs#L455),
[invalid stored
access](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/access_policy.rs#L98),
and the [local startup
guard](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/env_vars/tests.rs#L149).
- Consistent enforcement across [both agent
backends](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/access_policy.rs#L112).
- Welcome teammates created as [locally
run](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeGuide.test.mjs#L384)
or
[deployed](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeGuide.test.mjs#L393)
agents, including
[access-only](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeKickoff.test.mjs#L202)
and
[runtime-related](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeKickoff.test.mjs#L225)
restart behavior.

The full Desktop Rust and JavaScript suites, type checks, formatting,
clippy, and file-size checks passed. Playwright E2E was not run.

---

Originated from Buzz channel
[buzz-agent-control](buzz://channel?id=cf5dada7-e26a-4887-ae41-b3bd5f42d3b2).
Supersedes #2537.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Co-authored-by: Amp <amp@ampcode.com>
2026-08-05 19:58:24 -06:00

287 lines
12 KiB
Rust

use nostr::Keys;
use serde::{Deserialize, Serialize};
use std::sync::atomic::Ordering;
use tauri::{AppHandle, Emitter, Manager, State};
use crate::app_state::AppState;
use crate::managed_agents::{
effective_repos_dir, ensure_repos_symlink, nest_dir, restore_managed_agents_on_launch,
try_regenerate_nest, write_persisted_repos_dir,
};
use crate::relay;
/// Adopt the pre-scoping global retention database's pending rows into `scope`.
///
/// Best-effort: a failure is logged and the boot proceeds. The migration's own
/// crash-safety guards make the next launch retry safely, and blocking the
/// workspace apply on it would be worse than a delayed publish.
fn migrate_legacy_retention_into(
app: &AppHandle,
scope: &crate::managed_agents::retention::RetentionScope,
) {
let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else {
return;
};
match crate::managed_agents::retention::migrate_legacy_retention_db(
&base_dir,
&scope.db_path,
&scope.owner_keys.public_key().to_hex(),
) {
Ok(0) => {}
Ok(copied) => {
eprintln!("buzz-desktop: adopted {copied} legacy retained event(s) into this community")
}
Err(error) => eprintln!("buzz-desktop: legacy retention migration failed: {error}"),
}
}
#[derive(Deserialize)]
struct RelayInfoIcon {
#[serde(default)]
icon: Option<String>,
}
/// Fetch a relay's workspace icon from its NIP-11 relay information document.
///
/// Works for any workspace (active or not) with a plain unauthenticated HTTP
/// GET — no WebSocket session needed. Returns `None` when the relay has no
/// icon set, is unreachable, or serves a malformed document: the rail falls
/// back to initials in all three cases.
#[tauri::command]
pub async fn fetch_workspace_icon(
relay_url: String,
state: State<'_, AppState>,
) -> Result<Option<String>, String> {
let http_url = relay::relay_http_base_url(&relay_url);
let Ok(response) = state
.http_client
.get(&http_url)
.header("Accept", "application/nostr+json")
.send()
.await
else {
return Ok(None);
};
if !response.status().is_success() {
return Ok(None);
}
let doc = response
.json::<RelayInfoIcon>()
.await
.unwrap_or(RelayInfoIcon { icon: None });
Ok(doc.icon.filter(|icon| !icon.is_empty()))
}
#[derive(Serialize)]
pub struct ActiveWorkspaceInfo {
relay_url: String,
pubkey: String,
}
/// Returns the current active workspace info (relay URL + pubkey).
#[tauri::command]
pub fn get_active_workspace(state: State<'_, AppState>) -> Result<ActiveWorkspaceInfo, String> {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
let relay_url = relay::relay_ws_url_with_override(&state);
Ok(ActiveWorkspaceInfo {
relay_url,
pubkey: keys.public_key().to_hex(),
})
}
/// Validate a candidate `repos_dir` without mutating the filesystem.
///
/// The Add/Edit workspace dialogs call this on submit to block Save on a bad
/// path, so a typo never reaches `apply_workspace`. Reuses the same
/// `validate_repos_dir` the boot/apply path uses — one source of truth for
/// "what's a valid repos dir". An empty/whitespace value clears the override
/// and is valid. `Err` carries the human-readable reason for inline display.
#[tauri::command]
pub async fn validate_repos_dir(dir: String) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
let trimmed = dir.trim();
if trimmed.is_empty() {
return Ok(());
}
let nest = nest_dir().ok_or("cannot resolve home directory for nest")?;
crate::managed_agents::validate_repos_dir(&nest, trimmed).map(|_| ())
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
/// Apply a workspace's configuration to the backend session.
///
/// Called by the frontend on app init (after reload) to configure the
/// Tauri backend with the selected workspace's relay URL, keys, and repos
/// directory.
///
/// A bad `repos_dir` is non-fatal: relay/keys always apply (the relay is the
/// active workspace's own choice — orthogonal to the filesystem repos dir),
/// the bad value is NOT persisted (so the next boot starts clean), the
/// `REPOS` symlink is skipped (REPOS stays a real dir), a `repos-dir-error`
/// event surfaces the reason, and the command returns `Ok`. The dialogs
/// already block a bad path at Save (`validate_repos_dir`); this fallback only
/// catches a value that went bad after save (deleted dir, unmounted volume).
#[tauri::command]
pub async fn apply_workspace(
relay_url: String,
nsec: Option<String>,
repos_dir: Option<String>,
agent_managed_profiles: Option<bool>,
app: AppHandle,
) -> Result<(), String> {
let restore_app = app.clone();
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
// ── Validate before mutating ──────────────────────────────────────────
let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(nsec_trimmed) => {
Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?)
}
None => None,
};
// Decide the effective repos_dir from the candidate. A bad path does NOT
// reject — it is treated as if no override were set: relay/keys still
// apply, the bad value is not persisted, and a `repos-dir-error` surfaces
// the reason. Persisting a bad path would make every later boot read it,
// fail to resolve the symlink, and silently skip agent restore. One
// validate (inside `effective_repos_dir`) drives both the emit and the
// persisted value. `nest` is resolved softly: when absent there is nothing
// to persist or symlink, and relay/keys must still apply unconditionally.
let nest = nest_dir();
let effective_repos_dir = match nest.as_deref() {
Some(nest) => match effective_repos_dir(nest, repos_dir.as_deref()) {
Ok(value) => value,
Err(error) => {
let _ = app.emit("repos-dir-error", error);
None
}
},
None => None,
};
// ── Apply all state changes (nothing below can fail) ──────────────────
{
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
*override_guard = Some(relay_url);
}
// Reset the Rust-side admission gate when switching workspace/community,
// matching `resetRateLimitGate()` on the TS side (useCommunityInit.ts:38).
crate::relay_admission::reset_gate_for_workspace_change();
if let Some(keys) = parsed_keys {
let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?;
*keys_guard = keys;
}
// Keep the backend-side reconcile guard aligned with the frontend
// experiment before launch-time restore can spawn any agents. Missing
// means the stable behavior: desktop remains authoritative.
state
.managed_agent_profile_reconcile_enabled
.store(!agent_managed_profiles.unwrap_or(false), Ordering::Release);
// ── Filesystem side-effect (non-fatal) ────────────────────────────────
// Persist the *effective* repos_dir (None when the candidate failed
// validation) for the backend to read at boot, then re-point REPOS to
// match. Persisting first makes the dotfile authoritative even if the
// symlink apply fails here (e.g. a non-empty real REPOS): the next boot
// reads the persisted value and resolves the symlink before any agent can
// clone into REPOS. A bad candidate persists `None`, so the next boot is
// clean and agent restore proceeds. Failure of either must NOT fail the
// command — relay/keys are already applied. Surface symlink errors via
// `repos-dir-error`.
if let Some(nest) = nest.as_deref() {
if let Err(error) = write_persisted_repos_dir(nest, effective_repos_dir.as_deref()) {
eprintln!("buzz-desktop: persist repos dir failed: {error}");
}
if let Err(error) = ensure_repos_symlink(nest, effective_repos_dir.as_deref()) {
eprintln!("buzz-desktop: repos dir setup failed: {error}");
let _ = app.emit("repos-dir-error", error);
}
}
try_regenerate_nest(&app);
Ok::<(), String>(())
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
let state = restore_app.state::<AppState>();
super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?;
// Backfill this exact relay+owner scope only after the workspace has been
// applied. Running at process boot would target the fallback relay and
// collapse every community into one pending-event store.
match crate::managed_agents::retention::active_retention_scope(&restore_app, &state) {
Ok(scope) => {
// Adopt whatever the pre-scoping release left queued in the global
// retention database BEFORE the scoped reconcile and flush run, so
// stranded tombstones and archive requests publish on this boot
// instead of being abandoned by the storage cutover.
migrate_legacy_retention_into(&restore_app, &scope);
crate::event_sync::spawn_event_sync(
restore_app.clone(),
scope.owner_keys,
scope.db_path,
)
}
Err(error) => {
eprintln!("buzz-desktop: scoped event-sync unavailable after workspace apply: {error}");
}
}
let restore_pending = state
.managed_agent_restore_pending
.swap(false, Ordering::AcqRel);
// The coordinator starts before React applies the selected workspace, so
// its startup publication may have used the fallback relay and placeholder
// identity. Correct it off the command path so an unavailable relay cannot
// hold the frontend on its loading gate. On initial launch, restore MeshLLM
// first so a slow stopped-status request cannot overwrite a newly restored
// serving status, then restore managed agents after the admission identity
// has been published (or the bounded publication attempt has timed out).
#[cfg(feature = "mesh-llm")]
{
let app = restore_app.clone();
tauri::async_runtime::spawn(async move {
let state = app.state::<AppState>();
if restore_pending {
if let Err(error) =
crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await
{
eprintln!("buzz-desktop: failed to restore Share Compute: {error}");
}
}
crate::mesh_llm::publish_current_status_once(&app, "workspace apply").await;
if restore_pending {
if let Err(error) =
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
{
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
}
}
});
}
#[cfg(not(feature = "mesh-llm"))]
if restore_pending {
let app = restore_app.clone();
tauri::async_runtime::spawn(async move {
let state = app.state::<AppState>();
if let Err(error) =
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
{
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
}
});
}
Ok(())
}