mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
Guard remaining agent-keyed relay I/O
Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
This commit is contained in:
committed by
npub1re830n24qhgstulznk5dxdluccspxkxxdq643lm4q3zwwwjgsuqqmcmdrm
parent
93ea37c236
commit
adbc1112da
@@ -87,7 +87,8 @@ const overrides = new Map([
|
||||
// 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for
|
||||
// retry-safety. Load-bearing reviewer-required change; queued to split.
|
||||
// Consolidation removed the legacy persona-card import/export codecs.
|
||||
["src-tauri/src/commands/personas/mod.rs", 984],
|
||||
// internal-local-relay-lockdown round 5: preflight linked-agent profile updates.
|
||||
["src-tauri/src/commands/personas/mod.rs", 1018],
|
||||
// #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS
|
||||
// const + build_thread_replies_filter helper, mirroring the channel sibling so
|
||||
// the two p-gate filters can't drift) plus two guard unit tests. The file was
|
||||
|
||||
@@ -1083,6 +1083,7 @@ pub async fn start_managed_agent(
|
||||
|
||||
let reconcile = ProfileReconcileData {
|
||||
private_key_nsec: record.private_key_nsec.clone(),
|
||||
backend: record.backend.clone(),
|
||||
name: record.name.clone(),
|
||||
relay_url: record.relay_url.clone(),
|
||||
avatar_url: record.avatar_url.clone(),
|
||||
@@ -1325,7 +1326,7 @@ pub(crate) use deploy::resolve_deploy_model_provider;
|
||||
#[path = "agents_profile.rs"]
|
||||
mod profile;
|
||||
#[cfg(test)]
|
||||
use profile::{profile_needs_sync, resolve_legacy_avatar};
|
||||
use profile::{profile_needs_sync, resolve_legacy_avatar, validate_profile_reconcile_relay_with};
|
||||
pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -11,6 +11,7 @@ use super::*;
|
||||
|
||||
pub(crate) struct ProfileReconcileData {
|
||||
pub(crate) private_key_nsec: String,
|
||||
pub(crate) backend: BackendKind,
|
||||
pub(crate) name: String,
|
||||
pub(crate) relay_url: String,
|
||||
/// Expected avatar URL for the published profile. `None` for legacy records
|
||||
@@ -49,6 +50,17 @@ pub(super) fn resolve_legacy_avatar(
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn validate_profile_reconcile_relay_with<F>(
|
||||
data: &ProfileReconcileData,
|
||||
workspace_relay_url: &str,
|
||||
validate: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnOnce(&BackendKind, &str, &str) -> Result<(), String>,
|
||||
{
|
||||
validate(&data.backend, &data.relay_url, workspace_relay_url)
|
||||
}
|
||||
|
||||
/// Reconcile an agent's kind:0 profile on the relay.
|
||||
///
|
||||
/// Queries the relay for the agent's existing profile and re-publishes if missing
|
||||
@@ -74,13 +86,6 @@ pub(crate) async fn reconcile_agent_profile(
|
||||
) -> Result<(), String> {
|
||||
use crate::relay::{query_agent_profile, sync_managed_agent_profile};
|
||||
|
||||
// An explicit per-agent relay wins; an empty one falls back to the active
|
||||
// workspace relay. Resolved once and used for both the read and write-back.
|
||||
let relay_url = crate::relay::effective_agent_relay_url(
|
||||
&data.relay_url,
|
||||
&relay_ws_url_with_override(state),
|
||||
);
|
||||
|
||||
if !state
|
||||
.managed_agent_profile_reconcile_enabled
|
||||
.load(std::sync::atomic::Ordering::Acquire)
|
||||
@@ -88,6 +93,19 @@ pub(crate) async fn reconcile_agent_profile(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let workspace_relay_url = relay_ws_url_with_override(state);
|
||||
validate_profile_reconcile_relay_with(
|
||||
data,
|
||||
&workspace_relay_url,
|
||||
|backend, pin, workspace| {
|
||||
crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace)
|
||||
},
|
||||
)?;
|
||||
|
||||
// An explicit per-agent relay wins; an empty one falls back to the active
|
||||
// workspace relay. Resolved once and used for both the read and write-back.
|
||||
let relay_url = crate::relay::effective_agent_relay_url(&data.relay_url, &workspace_relay_url);
|
||||
|
||||
// Query the relay for the agent's existing kind:0 profile.
|
||||
let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?;
|
||||
|
||||
|
||||
@@ -414,3 +414,32 @@ fn deploy_payload_carries_the_full_behavioral_quad() {
|
||||
assert_eq!(payload["provider"], "openai");
|
||||
assert_eq!(payload["relay_url"], "wss://relay.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_reconcile_preflights_legacy_empty_pin_before_relay_io() {
|
||||
let data = ProfileReconcileData {
|
||||
private_key_nsec: String::new(),
|
||||
backend: BackendKind::Local,
|
||||
name: "legacy".into(),
|
||||
relay_url: String::new(),
|
||||
avatar_url: None,
|
||||
auth_tag: None,
|
||||
pubkey: "pubkey".into(),
|
||||
agent_command: "goose".into(),
|
||||
persona_id: None,
|
||||
};
|
||||
let calls = std::cell::Cell::new(0);
|
||||
let result = validate_profile_reconcile_relay_with(
|
||||
&data,
|
||||
"wss://public.example",
|
||||
|backend, pin, workspace| {
|
||||
calls.set(calls.get() + 1);
|
||||
assert_eq!(backend, &BackendKind::Local);
|
||||
assert!(pin.is_empty());
|
||||
assert_eq!(workspace, "wss://public.example");
|
||||
Err("blocked before query".into())
|
||||
},
|
||||
);
|
||||
assert_eq!(result.unwrap_err(), "blocked before query");
|
||||
assert_eq!(calls.get(), 1);
|
||||
}
|
||||
|
||||
@@ -144,6 +144,24 @@ fn propagate_persona_name_rename(
|
||||
renamed
|
||||
}
|
||||
|
||||
fn validate_persona_profile_updates_with<F>(
|
||||
records: &[ManagedAgentRecord],
|
||||
persona_id: &str,
|
||||
old_display_name: &str,
|
||||
name_changed: bool,
|
||||
avatar_changed: bool,
|
||||
mut validate: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnMut(&ManagedAgentRecord) -> Result<(), String>,
|
||||
{
|
||||
records
|
||||
.iter()
|
||||
.filter(|record| record.persona_id.as_deref() == Some(persona_id))
|
||||
.filter(|record| avatar_changed || (name_changed && record.name == old_display_name))
|
||||
.try_for_each(&mut validate)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_persona(
|
||||
input: UpdatePersonaRequest,
|
||||
@@ -176,10 +194,32 @@ pub async fn update_persona(
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("agent {} not found", input.id))?;
|
||||
|
||||
// Track what changed so we can propagate to linked agent records.
|
||||
// Track what changed so we can preflight every linked agent before
|
||||
// mutating either store or enqueueing agent-keyed profile I/O.
|
||||
let avatar_changed = persona.avatar_url != avatar_url;
|
||||
let name_changed = persona.display_name != display_name;
|
||||
let old_display_name = persona.display_name.clone();
|
||||
let workspace_relay = crate::relay::relay_ws_url_with_override(&state);
|
||||
let mut linked_records = if avatar_changed || name_changed {
|
||||
let records = load_managed_agents(&app)?;
|
||||
validate_persona_profile_updates_with(
|
||||
&records,
|
||||
&persona.id,
|
||||
&old_display_name,
|
||||
name_changed,
|
||||
avatar_changed,
|
||||
|record| {
|
||||
crate::managed_agents::validate_effective_local_agent_relay(
|
||||
&record.backend,
|
||||
&record.relay_url,
|
||||
&workspace_relay,
|
||||
)
|
||||
},
|
||||
)?;
|
||||
Some(records)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
persona.display_name = display_name;
|
||||
persona.avatar_url = avatar_url;
|
||||
@@ -209,11 +249,9 @@ pub async fn update_persona(
|
||||
|
||||
// If the avatar or display_name changed, propagate to linked agent
|
||||
// records and collect relay profile sync params for the async phase.
|
||||
let sync_params: ProfileSyncParams = if avatar_changed || name_changed {
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let sync_params: ProfileSyncParams = if let Some(mut records) = linked_records.take() {
|
||||
let mut params: ProfileSyncParams = Vec::new();
|
||||
let mut agents_modified = false;
|
||||
let workspace_relay = crate::relay::relay_ws_url_with_override(&state);
|
||||
|
||||
// Propagate the display_name rename to instances that still
|
||||
// carry the old definition display_name (pool-named instances
|
||||
|
||||
@@ -156,3 +156,44 @@ fn test_rename_renames_all_matching_instances_in_one_pass() {
|
||||
assert_eq!(records[1].name, "Duncan Idaho");
|
||||
assert_eq!(records[2].name, "Birch", "pool-named instance untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_profile_updates_preflight_only_records_that_would_mutate_or_publish() {
|
||||
let mut records = vec![
|
||||
agent("persona-1", "Paul", Some("Paul")),
|
||||
agent("persona-1", "Birch", Some("Birch")),
|
||||
agent("persona-2", "Paul", Some("Paul")),
|
||||
];
|
||||
records[0].backend = crate::managed_agents::BackendKind::Local;
|
||||
records[1].backend = crate::managed_agents::BackendKind::Provider {
|
||||
id: "provider".into(),
|
||||
config: serde_json::json!({}),
|
||||
};
|
||||
let visited = std::cell::RefCell::new(Vec::new());
|
||||
|
||||
validate_persona_profile_updates_with(&records, "persona-1", "Paul", true, false, |record| {
|
||||
visited.borrow_mut().push(record.pubkey.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(*visited.borrow(), vec!["pubkey-Paul"]);
|
||||
|
||||
visited.borrow_mut().clear();
|
||||
validate_persona_profile_updates_with(&records, "persona-1", "Paul", false, true, |record| {
|
||||
visited.borrow_mut().push(record.pubkey.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(*visited.borrow(), vec!["pubkey-Paul", "pubkey-Birch"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_profile_preflight_failure_happens_before_name_mutation() {
|
||||
let records = vec![agent("persona-1", "Paul", Some("Paul"))];
|
||||
let result =
|
||||
validate_persona_profile_updates_with(&records, "persona-1", "Paul", true, false, |_| {
|
||||
Err("blocked".into())
|
||||
});
|
||||
assert_eq!(result.unwrap_err(), "blocked");
|
||||
assert_eq!(records[0].name, "Paul");
|
||||
}
|
||||
|
||||
@@ -288,6 +288,20 @@ pub async fn preview_agent_snapshot_import(
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
pub(super) fn validate_snapshot_import_relay_with<F>(
|
||||
workspace_relay_url: &str,
|
||||
validate: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnOnce(&crate::managed_agents::BackendKind, &str, &str) -> Result<(), String>,
|
||||
{
|
||||
validate(
|
||||
&crate::managed_agents::BackendKind::Local,
|
||||
"",
|
||||
workspace_relay_url,
|
||||
)
|
||||
}
|
||||
|
||||
// ── `confirm_agent_snapshot_import` ──────────────────────────────────────────
|
||||
|
||||
/// Import a `buzz-agent-snapshot v1` file as a brand-new agent.
|
||||
@@ -321,6 +335,13 @@ pub async fn confirm_agent_snapshot_import(
|
||||
return Err("Snapshot display name is empty.".to_string());
|
||||
}
|
||||
|
||||
// Snapshot imports always mint a local agent with an empty relay pin. Reject
|
||||
// a disallowed effective workspace relay before key generation or store I/O.
|
||||
let workspace_relay_url = relay_ws_url_with_override(&state);
|
||||
validate_snapshot_import_relay_with(&workspace_relay_url, |backend, pin, workspace| {
|
||||
crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace)
|
||||
})?;
|
||||
|
||||
// ── Resolve behavioral defaults ──────────────────────────────────────────
|
||||
let minted = resolve_snapshot_import_behavior(
|
||||
snapshot.definition.respond_to.as_deref(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::import::{
|
||||
decode_snapshot_from_bytes, reject_legacy_persona_filename, resolve_snapshot_import_behavior,
|
||||
AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES,
|
||||
validate_snapshot_import_relay_with, AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES,
|
||||
MAX_SNAPSHOT_PNG_BYTES,
|
||||
};
|
||||
use super::*;
|
||||
use crate::managed_agents::{
|
||||
@@ -969,3 +970,18 @@ fn validate_encode_size_png_over_boundary_is_rejected() {
|
||||
"error must mention size limit, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn individual_snapshot_import_preflights_empty_pin_before_mint_or_store() {
|
||||
let calls = std::cell::Cell::new(0);
|
||||
let result =
|
||||
validate_snapshot_import_relay_with("wss://public.example", |backend, pin, relay| {
|
||||
calls.set(calls.get() + 1);
|
||||
assert_eq!(backend, &BackendKind::Local);
|
||||
assert!(pin.is_empty());
|
||||
assert_eq!(relay, "wss://public.example");
|
||||
Err("blocked before mutation".into())
|
||||
});
|
||||
assert_eq!(result.unwrap_err(), "blocked before mutation");
|
||||
assert_eq!(calls.get(), 1);
|
||||
}
|
||||
|
||||
@@ -496,6 +496,20 @@ pub async fn preview_team_snapshot_import(
|
||||
///
|
||||
/// Importing the same file twice yields two distinct teams with different
|
||||
/// agent keypairs (same as individual agent import).
|
||||
fn validate_team_snapshot_import_relay_with<F>(
|
||||
workspace_relay_url: &str,
|
||||
validate: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnOnce(&crate::managed_agents::BackendKind, &str, &str) -> Result<(), String>,
|
||||
{
|
||||
validate(
|
||||
&crate::managed_agents::BackendKind::Local,
|
||||
"",
|
||||
workspace_relay_url,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn confirm_team_snapshot_import(
|
||||
input: TeamSnapshotImportConfirm,
|
||||
@@ -506,6 +520,13 @@ pub async fn confirm_team_snapshot_import(
|
||||
let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?;
|
||||
let now = now_iso();
|
||||
|
||||
// Team snapshots mint only empty-pin local agents. Preflight the workspace
|
||||
// relay once before generating any member key or mutating any store.
|
||||
let workspace_relay_url = relay_ws_url_with_override(&state);
|
||||
validate_team_snapshot_import_relay_with(&workspace_relay_url, |backend, pin, workspace| {
|
||||
crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace)
|
||||
})?;
|
||||
|
||||
// Resolve behavioral defaults for every member before any key generation.
|
||||
let definitions = build_import_definitions(&snapshot, input.keep_allowlist, &now)?;
|
||||
let persona_ids: Vec<String> = definitions.iter().map(|d| d.id.clone()).collect();
|
||||
|
||||
@@ -724,3 +724,18 @@ fn full_rollback_at_teams_boundary_absent_agents_store() {
|
||||
assert!(!teams_path.exists());
|
||||
assert_eq!(errors.len(), 1, "only the teams-write error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_snapshot_import_preflights_empty_pin_once_before_mint_or_store() {
|
||||
let calls = std::cell::Cell::new(0);
|
||||
let result =
|
||||
validate_team_snapshot_import_relay_with("wss://public.example", |backend, pin, relay| {
|
||||
calls.set(calls.get() + 1);
|
||||
assert_eq!(backend, &crate::managed_agents::BackendKind::Local);
|
||||
assert!(pin.is_empty());
|
||||
assert_eq!(relay, "wss://public.example");
|
||||
Err("blocked before mutation".into())
|
||||
});
|
||||
assert_eq!(result.unwrap_err(), "blocked before mutation");
|
||||
assert_eq!(calls.get(), 1);
|
||||
}
|
||||
|
||||
@@ -422,6 +422,7 @@ pub async fn restore_managed_agents_on_launch(
|
||||
pubkey.clone(),
|
||||
crate::commands::ProfileReconcileData {
|
||||
private_key_nsec: record.private_key_nsec.clone(),
|
||||
backend: record.backend.clone(),
|
||||
name: record.name.clone(),
|
||||
relay_url: record.relay_url.clone(),
|
||||
avatar_url: record.avatar_url.clone(),
|
||||
|
||||
Reference in New Issue
Block a user