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:
7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz
2026-07-23 13:34:01 -07:00
committed by npub1re830n24qhgstulznk5dxdluccspxkxxdq643lm4q3zwwwjgsuqqmcmdrm
parent 93ea37c236
commit adbc1112da
11 changed files with 216 additions and 14 deletions

View File

@@ -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

View File

@@ -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)]

View File

@@ -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?;

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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");
}

View File

@@ -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(),

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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(),