Files
buzz/desktop/src-tauri/src/commands/agents_profile.rs
Brad Seiler 96af488d96 fix(media): revoke private channel attachment reads (#9077)
## Summary

Removed private-channel members can currently download attachments if
they know the URL. With `BUZZ_MEDIA_READ_MODE=enforce`, fresh GET, HEAD
and Range requests check current membership and readable references on
the primary database. Removed members receive 404; remaining members
retain access, and rejoining restores access. Thumbnail aliases use the
parent attachment’s permissions.

Publication checks also prevent copied private URLs from granting new
access. They cover attachments, link previews, profile pictures,
banners, group pictures and emoji. Persona creation and changed-avatar
edits, plus team and individual snapshot imports, transfer animated
posters and animations under the agent’s signer before profile
publication. Failed persona transfers preserve existing local records;
import failures appear in the results.

Authorization uses bounded queries, a two-second statement deadline and
transaction-local JIT suppression. An index on edit targets avoids broad
scans. Publications have a 512-reference limit, and capacity waits up to
500 ms before refusal.

Protection defaults off. Existing downloaded bytes cannot be recalled.
All modes stop creating immutable HTTP cache entries.

### Related issue

Related approaches: #7565, #5185, #8451 and #8444. This change evaluates
live stored references directly.

### Testing

Published head: `3d1aa7431cc495a001a0f13e95e2e422c5a77ab1`, rebased on
main `2cd40badb85de059a6916206e133da7a0a88fb2e`. Push hooks were
**SKIPPED** with explicit author approval. Current-head HTTP, full
package, PostgreSQL migration/media and profile-filter mutation checks
are pending; CI is pending. The earlier complete HTTP journey passed at
`f20b449262` (identical source tree), with its evidence attributed
separately. Earlier failing preview/report-fixture runs are preserved;
they do not count as passes.

The HTTP journey covers upload/publication, fresh GET/HEAD/Range
revocation and rejoin, emoji replacement, edited previews, persona
visibility and non-reference decoys. The migration regression forces an
index timeout, verifies no 0086 history or media tables, then proves a
migrator through the base's 0085 can restart.

Human attachment removal/rejoin testing passed on the pinned
`1f08a8e0fd2edf19dc94321f7258518149fe1bf6` trial. Native animated
persona edits and team/individual import acceptance remain incomplete.
That old-snapshot database stays intact and cannot run the reconciled
binary; a new trial needs a separate fresh database.

### Rollout prerequisites

- Build and verify the reference index before shadow mode. Small
databases can auto-migrate through one atomic 0086 migration within the
configured timeouts. It builds the index before creating the media
tables, so failure records no new migration version. Large deployments
must disable auto-migration and prebuild indexes concurrently.
- **Enforce remains blocked unless the operator can rule out SDK/CLI
attachment edits and the unverified agent-harness edit producer.**
SDK/CLI edits still omit attachment metadata. That behavior is deferred,
not fixed. Removing this blocker requires latest-effective attachment
metadata across successive edits, no restoration of removed files, and
an upload → successive edits → GET/HEAD regression.
- Validate client signer compatibility and split emoji publications
above 512 references before enforce mode.
- Complete the human workflows above in an isolated community with
enforce enabled.
- Follow the [index deployment
guide](https://github.com/block/buzz/blob/honor/private-channel-media/docs/private-media-index-deployment.md)
and [operator
guidance](https://github.com/block/buzz/blob/honor/private-channel-media/docs/operator-private-media.md)
for rollout and rollback.

The [prebuild timeout
finding](https://github.com/block/buzz/pull/9077#discussion_r4486221478)
and [avatar-transfer
finding](https://github.com/block/buzz/pull/9077#discussion_r4486221458)
are implemented and independently reviewed.

### Schema change checklist

- [x] Additive expand and code deployment; no backfill or contract
phase. Prebuild/verify before shadow under either schema deployment
path.
- [x] No SQL function, trigger, procedure, rule or foreign key added.
Signed events are untouched.
- [x] No migration mutates existing rows.
- [x] Migration and desired-state schema match. Catalog parity, target
parsing, future partition inheritance and upgrade refusal are covered.
- [x] Old/new binaries coexist for serving. Suspend community deletion
during expansion and while rolled back to a binary with the old deletion
catalog.
- [x] Keep auto-migration disabled on old binaries after schema
expansion; sqlx rejects unknown versions.
- [x] Migration lock timeout 1 s; statement timeout 5 s. Small populated
databases build inline within those budgets; large deployments use
concurrent child-index prebuild/attachment with auto-migration off.
Missing, invalid or wrong indexes fail verification.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-06 22:35:00 +00:00

418 lines
16 KiB
Rust

//! Agent kind:0 profile reconciliation — split from `agents.rs` (file-size
//! guard). Owns the reconcile data carrier, the legacy-avatar backfill, and
//! the needs-sync predicate.
use tauri::{AppHandle, Manager};
use crate::app_state::AppState;
use crate::managed_agents::managed_agent_avatar_url;
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProfileReconcileOutcome {
Reconciled,
SkippedDisabled,
}
pub(crate) struct ProfileReconcileData {
pub(crate) private_key_nsec: String,
pub(crate) name: String,
pub(crate) relay_url: String,
/// Exact relay pinned by the caller for the deferred task — captured
/// while the authorizing workspace/spawn was active (UI start, boot
/// restore, migration queue). When set it wins unconditionally; a task
/// left unpinned (no tenant boundary) resolves the current workspace at
/// execution time. See `resolve_reconcile_relay`.
pub(crate) target_relay_url: Option<String>,
/// Saved avatar source, localized to the target community before publishing.
/// `None` for legacy records
/// that predate the `avatar_url` field — these will be backfilled from the
/// relay's existing kind:0 profile on first reconciliation.
pub(crate) avatar_url: Option<String>,
pub(crate) auth_tag: Option<String>,
/// The agent's pubkey (hex). Needed to update the persisted record during
/// avatar backfill migration.
pub(crate) pubkey: String,
/// The agent's command (e.g. "goose"). Used as fallback when no profile
/// exists on the relay during avatar backfill.
pub(crate) agent_command: String,
/// Persona ID if this agent was created from a persona. Used during avatar
/// backfill to recover the correct avatar from the persona record when the
/// relay profile has been corrupted.
pub(crate) persona_id: Option<String>,
/// Expected kind:0 `about` — the agent's effective public description
/// (owner-authored when present; see
/// `managed_agents::record_effective_description`). `None` publishes an
/// about-less profile.
pub(crate) about: Option<String>,
}
/// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no
/// stored `avatar_url`).
///
/// Priority: the persona's avatar wins, because the old reconciliation code
/// could have overwritten the relay's kind:0 `picture` with the command default
/// — making the relay an unreliable source for persona-backed agents. Only fall
/// back to the relay's `picture`, then the command icon, for agents with no
/// persona avatar to recover from.
pub(super) fn resolve_legacy_avatar(
persona_avatar: Option<String>,
relay_picture: Option<String>,
agent_command: &str,
) -> String {
persona_avatar
.or(relay_picture)
.or_else(|| managed_agent_avatar_url(agent_command))
.unwrap_or_default()
}
/// Resolve the relay a reconciliation task will query and publish on. The
/// pure core of the `reconcile_agent_profile` relay choice, extracted so the
/// pinning contract is unit-testable: a caller-pinned `target_relay_url`
/// (captured while the authorizing workspace was active) wins UNCONDITIONALLY
/// over the execution-time workspace read — otherwise a community switch
/// landing between spawn and execution would retarget the kind:0
/// query/publish to a tenant the caller never authorized. Only an unpinned
/// task (no tenant boundary) resolves the live workspace.
pub(super) fn resolve_reconcile_relay(
target_relay_url: Option<&str>,
record_relay_url: &str,
workspace_relay_at_execution: &str,
) -> String {
match target_relay_url {
Some(pinned) => pinned.to_string(),
None => {
crate::relay::effective_agent_relay_url(record_relay_url, workspace_relay_at_execution)
}
}
}
pub(crate) fn profile_reconcile_data(
record: &crate::managed_agents::ManagedAgentRecord,
personas: &[crate::managed_agents::AgentDefinition],
) -> ProfileReconcileData {
ProfileReconcileData {
private_key_nsec: record.private_key_nsec.clone(),
name: record.name.clone(),
relay_url: record.relay_url.clone(),
target_relay_url: None,
avatar_url: record.avatar_url.clone(),
auth_tag: record.auth_tag.clone(),
pubkey: record.pubkey.clone(),
agent_command: crate::managed_agents::record_agent_command(record, personas),
persona_id: record.persona_id.clone(),
about: crate::managed_agents::record_effective_description(record, personas),
}
}
pub(crate) fn load_pending_profile_reconciliations(
app: &AppHandle,
workspace_relay: &str,
) -> Result<Vec<(String, ProfileReconcileData)>, String> {
let state = app.state::<AppState>();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let store_path = crate::managed_agents::managed_agents_store_path(app)?;
let queue_path = crate::migration::profile_reconcile_queue_path(&store_path);
if !queue_path.exists() {
return Ok(Vec::new());
}
let relay_key = crate::migration::profile_reconcile_relay_key(workspace_relay)?;
let pending = crate::migration::read_profile_reconcile_queue(&queue_path)?;
let records = crate::managed_agents::load_managed_agents(app)?;
let personas = crate::managed_agents::load_personas(app).unwrap_or_default();
Ok(records
.iter()
// A queue write deliberately precedes the migrated agent-store write.
// If the process dies between them, retain (but do not execute) the
// stale item until the next boot finishes renaming the record.
.filter(|record| {
pending.iter().any(|entry| {
entry.pubkey == record.pubkey
&& entry.expected_name == record.name
&& !entry
.reconciled_relays
.iter()
.any(|relay| relay == &relay_key)
})
})
.map(|record| {
let mut data = profile_reconcile_data(record, &personas);
// Pin the relay captured by the caller. Otherwise a fast community
// switch could make a queued task for A run on B.
data.target_relay_url = Some(workspace_relay.to_string());
(record.pubkey.clone(), data)
})
.collect())
}
pub(crate) fn mark_profile_reconciled(
app: &AppHandle,
pubkey: &str,
relay_url: &str,
) -> Result<(), String> {
let state = app.state::<AppState>();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let store_path = crate::managed_agents::managed_agents_store_path(app)?;
let queue_path = crate::migration::profile_reconcile_queue_path(&store_path);
if !queue_path.exists() {
return Ok(());
}
let relay_key = crate::migration::profile_reconcile_relay_key(relay_url)?;
let mut pending = crate::migration::read_profile_reconcile_queue(&queue_path)?;
crate::migration::record_profile_reconciled(&mut pending, pubkey, relay_key);
crate::migration::write_profile_reconcile_queue(&queue_path, &pending)
}
/// Reconcile an agent's kind:0 profile on the relay.
///
/// Queries the relay for the agent's existing profile and re-publishes if missing
/// or stale (display_name or picture mismatch). This is fire-and-forget — errors
/// are returned to the caller for logging but never block agent startup.
///
/// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function
/// backfills via `resolve_legacy_avatar` — preferring the persona record's avatar
/// over the relay's `picture`, since the old code may have corrupted the relay
/// profile — and persists the updated record. After backfill, normal
/// reconciliation proceeds.
///
/// Query and publish target the caller-pinned `target_relay_url` when set
/// (UI start, boot restore, migration queue — captured while the authorizing
/// workspace was active); an unpinned task falls back to
/// `effective_agent_relay_url` against the workspace at execution time. This
/// keeps deferred reconciliation from following a community switch it was
/// never authorized for while honoring a deliberate per-agent pin wherever
/// it points.
pub(crate) async fn reconcile_agent_profile<R: tauri::Runtime>(
state: &AppState,
app: &AppHandle<R>,
agent_pubkey: &str,
data: &ProfileReconcileData,
) -> Result<ProfileReconcileOutcome, String> {
use crate::relay::query_agent_profile;
// Resolved ONCE and used for both the read and the write-back. A pinned
// `target_relay_url` wins unconditionally — see `resolve_reconcile_relay`.
let relay_url = resolve_reconcile_relay(
data.target_relay_url.as_deref(),
&data.relay_url,
&relay_ws_url_with_override(state),
);
if !state
.managed_agent_profile_reconcile_enabled()
.load(std::sync::atomic::Ordering::Acquire)
{
return Ok(ProfileReconcileOutcome::SkippedDisabled);
}
// Query the relay for the agent's existing kind:0 profile.
let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?;
// Resolve the expected avatar — backfilling for legacy records that have no
// stored avatar_url yet.
let expected_avatar = match data.avatar_url.as_deref() {
Some(url) => url.to_string(),
None => {
// Legacy record: the relay profile may have been corrupted by the
// old reconciliation code (it overwrote the persona avatar with the
// command default), so the persona record is the authoritative source.
let persona_avatar = data.persona_id.as_ref().and_then(|pid| {
load_personas(app)
.ok()?
.into_iter()
.find(|p| p.id == *pid)?
.avatar_url
});
let backfilled = resolve_legacy_avatar(
persona_avatar,
existing.as_ref().and_then(|info| info.picture.clone()),
&data.agent_command,
);
// Persist the backfilled avatar so this migration only runs once.
if !backfilled.is_empty() {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut records = load_managed_agents(app)?;
if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) {
record.avatar_url = Some(backfilled.clone());
save_managed_agents(app, &records)?;
}
}
backfilled
}
};
let expected_avatar = if expected_avatar.is_empty() {
None
} else {
Some(expected_avatar)
};
reconcile_profile_at(
state,
&relay_url,
data,
expected_avatar.as_deref(),
existing.as_ref(),
)
.await
}
/// Network half of startup/restore reconciliation, after legacy source backfill.
/// Kept separate from the disk migration so the production compare-and-publish
/// seam can be exercised against two communities without a GUI runtime.
pub(crate) async fn reconcile_profile_at(
state: &AppState,
relay_url: &str,
data: &ProfileReconcileData,
expected_avatar: Option<&str>,
existing: Option<&crate::relay::AgentProfileInfo>,
) -> Result<ProfileReconcileOutcome, String> {
if !state
.managed_agent_profile_reconcile_enabled()
.load(std::sync::atomic::Ordering::Acquire)
{
return Ok(ProfileReconcileOutcome::SkippedDisabled);
}
let agent_keys = Keys::parse(&data.private_key_nsec)
.map_err(|e| format!("failed to parse agent keys: {e}"))?;
let expected_avatar = crate::relay::profile_avatar::localize_avatar(
state,
relay_url,
&agent_keys,
expected_avatar,
data.auth_tag.as_deref(),
)
.await?;
if !profile_needs_sync(
existing,
&data.name,
expected_avatar.as_deref(),
data.about.as_deref(),
) {
return Ok(ProfileReconcileOutcome::Reconciled);
}
if !state
.managed_agent_profile_reconcile_enabled()
.load(std::sync::atomic::Ordering::Acquire)
{
return Ok(ProfileReconcileOutcome::SkippedDisabled);
}
crate::relay::sync_managed_agent_profile(
state,
relay_url,
&agent_keys,
&data.name,
expected_avatar.as_deref(),
data.about.as_deref(),
data.auth_tag.as_deref(),
)
.await?;
Ok(ProfileReconcileOutcome::Reconciled)
}
/// Decide whether a published profile is missing or stale relative to the
/// expected name, avatar, and about. A missing profile always needs sync; a
/// present one is stale when the display name, picture, or about diverges.
/// For about, `None` and the empty string are treated as equal so an
/// about-less profile never triggers a pointless republish loop.
pub(super) fn profile_needs_sync(
existing: Option<&crate::relay::AgentProfileInfo>,
expected_name: &str,
expected_avatar: Option<&str>,
expected_about: Option<&str>,
) -> bool {
match existing {
None => true,
Some(info) => {
let name_matches = info.display_name.as_deref() == Some(expected_name);
let picture_matches = info.picture.as_deref() == expected_avatar;
let about_matches = info.about.as_deref().unwrap_or("") == expected_about.unwrap_or("");
!name_matches || !picture_matches || !about_matches
}
}
}
/// Capture authority while the creating workspace is still active, before awaits.
pub(crate) fn creation_authority(
state: &AppState,
relay: &str,
) -> Result<(nostr::Keys, String), String> {
Ok((
state.signing_keys()?,
crate::relay::effective_agent_relay_url(relay, &relay_ws_url_with_override(state)),
))
}
/// Explicit owner action transfers verified bytes; reconcile has no owner signer.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn publish_created_agent_profile(
state: &AppState,
relay: &str,
owner: &nostr::Keys,
agent: &nostr::Keys,
name: &str,
avatar: Option<&str>,
about: Option<&str>,
auth_tag: Option<&str>,
) -> Option<String> {
if let Err(error) = crate::relay::profile_avatar::transfer_created_avatar(
state, relay, owner, agent, avatar, auth_tag,
)
.await
{
return Some(error);
}
crate::relay::sync_managed_agent_profile(state, relay, agent, name, avatar, about, auth_tag)
.await
.err()
}
/// Publish a fresh persona-backed agent's kind:0 profile, computing the
/// effective public `about` from the persona itself.
/// Shared by flows in files at the size ratchet (snapshot import).
#[allow(clippy::too_many_arguments)] // Complete captured authority stays explicit.
pub(crate) async fn publish_persona_profile(
state: &AppState,
relay_url: &str,
owner_keys: &nostr::Keys,
agent_keys: &nostr::Keys,
display_name: &str,
avatar_url: Option<&str>,
persona: &crate::managed_agents::AgentDefinition,
auth_tag: Option<&str>,
) -> Option<String> {
let about = crate::managed_agents::effective_agent_description(persona.description.as_deref());
publish_created_agent_profile(
state,
relay_url,
owner_keys,
agent_keys,
display_name,
avatar_url,
about.as_deref(),
auth_tag,
)
.await
}
// Async so the blocking body (disk reads/writes + process termination) runs off
// the main UI thread via spawn_blocking. State is re-derived from the owned
// AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send).