mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): enforce shared agent access across devices (#6086)
## Summary - discover shared managed agents from authenticated relay directory records instead of treating channel membership as sufficient proof - publish and refresh access-policy changes immediately so running clients converge across machines without a restart or five-minute poll - route profile edits through the exact managed instance and stop/restart runtimes around access changes so unrelated edits cannot silently widen access - keep mention send-time revalidation and Block owner-only build enforcement fail closed - explain invalid custom provider/model configuration instead of leaving Save silently disabled ### Related issue Fixes #3204 ### Known residuals - a brand-new remote agent's first policy record can wait for the bounded directory poll when no authenticated directory coordinate exists yet; send-time mention revalidation remains fail closed - a failed remote-provider policy redeploy is recorded but cannot undeploy the older provider instance until the provider protocol gains the destructor tracked by #5570 ### Testing - full Desktop unit suite: 4,961 tests passed - focused profile editor Playwright workflow passed, including Customize access edits and prompt-only edits after tightening an instance - Desktop TypeScript, Biome formatting, file-size ratchet, Tauri checks, and pre-push suites passed - independently reviewed for authenticated directory trust, live subscription teardown, runtime revocation ordering, fail-open edit paths, and per-agent provider deployment serialization --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Signed-off-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> Co-authored-by: diegorumo <diegorumo@gmail.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
This commit is contained in:
co-authored by
diegorumo
Carl
Brain
parent
1b7e5ac1be
commit
f716eef437
@@ -39,9 +39,7 @@ pub struct AppState {
|
||||
/// restore. `apply_workspace` consumes it after installing the workspace
|
||||
/// relay and identity, so agents never start against the fallback relay.
|
||||
pub managed_agent_restore_pending: AtomicBool,
|
||||
/// Whether desktop may repair managed-agent kind:0 profiles from its local
|
||||
/// records. Disabled by the agent-managed profiles experiment so an agent's
|
||||
/// own profile updates are not overwritten on start or restore.
|
||||
/// Disabled by agent-managed profiles so agent profile updates survive start/restore.
|
||||
pub managed_agent_profile_reconcile_enabled: AtomicBool,
|
||||
/// Shared shutdown signal checked by launch-time agent restoration.
|
||||
pub shutdown_started: AtomicBool,
|
||||
@@ -52,6 +50,7 @@ pub struct AppState {
|
||||
pub managed_agents_store_lock: Mutex<()>,
|
||||
pub channel_templates_store_lock: Mutex<()>,
|
||||
pub managed_agent_processes: Mutex<HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>>,
|
||||
pub provider_deploy_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
pub huddle_state: Mutex<HuddleState>,
|
||||
pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState,
|
||||
/// Tauri app handle — stored after setup so huddle commands can emit
|
||||
@@ -215,6 +214,7 @@ pub fn build_app_state() -> AppState {
|
||||
managed_agents_store_lock: Mutex::new(()),
|
||||
channel_templates_store_lock: Mutex::new(()),
|
||||
managed_agent_processes: Mutex::new(HashMap::new()),
|
||||
provider_deploy_locks: Mutex::new(HashMap::new()),
|
||||
session_config_cache: Mutex::new(HashMap::new()),
|
||||
huddle_state: Mutex::new(HuddleState::default()),
|
||||
huddle_audio: Default::default(),
|
||||
|
||||
@@ -4,6 +4,19 @@ pub fn agent_access_owner_only() -> bool {
|
||||
crate::managed_agents::owner_only_access_build()
|
||||
}
|
||||
|
||||
/// Tiny executable-facing probe for release packaging smoke tests. Keeping the
|
||||
/// probe in the product crate makes it impossible for buzz-releases to validate
|
||||
/// a copied flag interpretation that has drifted from Desktop's command.
|
||||
#[doc(hidden)]
|
||||
pub fn print_agent_access_owner_only_probe_if_requested() -> bool {
|
||||
if std::env::args().any(|arg| arg == "--print-agent-access-owner-only") {
|
||||
println!("{}", agent_access_owner_only());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -89,6 +89,7 @@ fn agent_record() -> ManagedAgentRecord {
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
command_availability, is_npm_global_install, AcpRuntimeCatalogEntry,
|
||||
DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo,
|
||||
RelayAgentInfo, DEFAULT_ACP_COMMAND,
|
||||
},
|
||||
nostr_convert,
|
||||
relay::query_relay,
|
||||
use crate::managed_agents::{
|
||||
command_availability, is_npm_global_install, AcpRuntimeCatalogEntry,
|
||||
DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo,
|
||||
DEFAULT_ACP_COMMAND,
|
||||
};
|
||||
|
||||
mod post_install_verification;
|
||||
@@ -1037,31 +1030,31 @@ pub async fn discover_managed_agent_prereqs(
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_relay_agents(state: State<'_, AppState>) -> Result<Vec<RelayAgentInfo>, String> {
|
||||
// Query kind:10100 agent profile events from the relay.
|
||||
let events = query_relay(
|
||||
&state,
|
||||
&[serde_json::json!({
|
||||
"kinds": [10100],
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The convert helper returns `{"agents": [...]}`. Extract and re-deserialize
|
||||
// into the strongly-typed `Vec<RelayAgentInfo>` the frontend expects.
|
||||
let value = nostr_convert::agents_from_events(&events);
|
||||
let agents = value
|
||||
.get("agents")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!([]));
|
||||
serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}"))
|
||||
}
|
||||
mod relay_directory;
|
||||
#[cfg(test)]
|
||||
use relay_directory::advance_relay_cursor;
|
||||
pub use relay_directory::list_relay_agents;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relay_directory_cursor_uses_timestamp_and_event_id() {
|
||||
use nostr::{EventBuilder, Keys, Kind, Timestamp};
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(30177), "{}")
|
||||
.custom_created_at(Timestamp::from(42))
|
||||
.sign_with_keys(&Keys::generate())
|
||||
.expect("sign cursor event");
|
||||
let mut filter = serde_json::json!({"kinds": [30177]});
|
||||
|
||||
advance_relay_cursor(&mut filter, std::slice::from_ref(&event));
|
||||
|
||||
assert_eq!(filter["until"], 42);
|
||||
assert_eq!(filter["before_id"], event.id.to_hex());
|
||||
}
|
||||
|
||||
// ── is_npm_global_install ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
//! Relay-backed shared-agent directory discovery.
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::{
|
||||
app_state::AppState, commands::identity_archive, managed_agents::RelayAgentInfo, nostr_convert,
|
||||
relay::query_relay,
|
||||
};
|
||||
|
||||
const RELAY_DIRECTORY_PAGE_SIZE: usize = 500;
|
||||
const RELAY_FILTER_BATCH_SIZE: usize = 10;
|
||||
|
||||
fn exact_author_filters(pubkeys: &[String], kind: u16) -> Vec<serde_json::Value> {
|
||||
pubkeys
|
||||
.iter()
|
||||
.map(|pubkey| {
|
||||
serde_json::json!({
|
||||
"authors": [pubkey],
|
||||
"kinds": [kind],
|
||||
"limit": 1,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn managed_policy_filters(
|
||||
candidate_pubkeys: &[String],
|
||||
verified_owners: &std::collections::HashMap<String, String>,
|
||||
) -> Vec<serde_json::Value> {
|
||||
candidate_pubkeys
|
||||
.iter()
|
||||
.filter_map(|agent_pubkey| {
|
||||
verified_owners.get(agent_pubkey).map(|owner_pubkey| {
|
||||
serde_json::json!({
|
||||
"authors": [owner_pubkey],
|
||||
"kinds": [30177],
|
||||
"#d": [agent_pubkey],
|
||||
"limit": 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn current_user_pubkey(state: &AppState) -> Result<String, String> {
|
||||
state
|
||||
.keys
|
||||
.lock()
|
||||
.map(|keys| keys.public_key().to_hex())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn advance_relay_cursor(filter: &mut serde_json::Value, page: &[nostr::Event]) {
|
||||
let last = page
|
||||
.last()
|
||||
.expect("a full relay page always has a last event");
|
||||
filter["until"] = serde_json::json!(last.created_at.as_secs());
|
||||
filter["before_id"] = serde_json::json!(last.id.to_hex());
|
||||
}
|
||||
|
||||
async fn query_all_relay_pages(
|
||||
state: &AppState,
|
||||
mut filter: serde_json::Value,
|
||||
) -> Result<Vec<nostr::Event>, String> {
|
||||
filter["limit"] = serde_json::json!(RELAY_DIRECTORY_PAGE_SIZE);
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
let page = query_relay(state, &[filter.clone()]).await?;
|
||||
let done = page.len() < RELAY_DIRECTORY_PAGE_SIZE;
|
||||
if !done {
|
||||
advance_relay_cursor(&mut filter, &page);
|
||||
}
|
||||
events.extend(page);
|
||||
if done {
|
||||
return Ok(events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn list_relay_agents_for_state(
|
||||
state: &AppState,
|
||||
) -> Result<Vec<RelayAgentInfo>, String> {
|
||||
let viewer_pubkey = current_user_pubkey(state)?;
|
||||
let relay_pubkey = identity_archive::fetch_relay_self(state)
|
||||
.await?
|
||||
.ok_or_else(|| "relay agent membership authority is unavailable".to_string())?;
|
||||
|
||||
// Membership is the authoritative and bounded candidate source. Only
|
||||
// channels visible to this identity are read, and only bot-role p-tags can
|
||||
// drive the downstream managed-policy and owner-profile lookups.
|
||||
let membership_events = query_all_relay_pages(
|
||||
state,
|
||||
serde_json::json!({
|
||||
"kinds": [39002],
|
||||
"authors": [&relay_pubkey],
|
||||
"#p": [&viewer_pubkey],
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("relay agent channel-membership query failed: {error}"))?;
|
||||
let member_agent_channel_ids =
|
||||
nostr_convert::member_agent_channel_ids_from_events(&membership_events, &relay_pubkey);
|
||||
let candidate_pubkeys: Vec<String> = member_agent_channel_ids.keys().cloned().collect();
|
||||
if candidate_pubkeys.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut directory_events = Vec::new();
|
||||
let mut profile_events = Vec::new();
|
||||
let directory_filters = exact_author_filters(&candidate_pubkeys, 10100);
|
||||
let profile_filters = exact_author_filters(&candidate_pubkeys, 0);
|
||||
for filter_offset in (0..candidate_pubkeys.len()).step_by(RELAY_FILTER_BATCH_SIZE) {
|
||||
let filter_end = (filter_offset + RELAY_FILTER_BATCH_SIZE).min(candidate_pubkeys.len());
|
||||
let (directory, profiles) = tokio::join!(
|
||||
query_relay(state, &directory_filters[filter_offset..filter_end]),
|
||||
query_relay(state, &profile_filters[filter_offset..filter_end]),
|
||||
);
|
||||
directory_events.extend(
|
||||
directory
|
||||
.map_err(|error| format!("relay agent runtime-directory query failed: {error}"))?,
|
||||
);
|
||||
profile_events.extend(
|
||||
profiles.map_err(|error| format!("relay agent owner-profile query failed: {error}"))?,
|
||||
);
|
||||
}
|
||||
|
||||
// Only the agent's signed NIP-OA profile can name the owner coordinate to
|
||||
// query. Each exact `(owner, d=agent)` filter returns at most one current
|
||||
// replaceable event, so forged 30177 coordinates cannot amplify or crowd
|
||||
// the authentic policy out of a bounded result page.
|
||||
let verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events);
|
||||
let managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners);
|
||||
let mut managed_agent_events = Vec::new();
|
||||
for filters in managed_filters.chunks(RELAY_FILTER_BATCH_SIZE) {
|
||||
managed_agent_events.extend(
|
||||
query_relay(state, filters)
|
||||
.await
|
||||
.map_err(|error| format!("relay agent managed-policy query failed: {error}"))?,
|
||||
);
|
||||
}
|
||||
|
||||
let mut agents = nostr_convert::relay_agents_from_directory_events(
|
||||
&directory_events,
|
||||
&managed_agent_events,
|
||||
&profile_events,
|
||||
);
|
||||
agents.retain(|agent| member_agent_channel_ids.contains_key(&agent.pubkey));
|
||||
for agent in &mut agents {
|
||||
agent.channel_ids = member_agent_channel_ids
|
||||
.get(&agent.pubkey)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
}
|
||||
Ok(agents)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_relay_agents(state: State<'_, AppState>) -> Result<Vec<RelayAgentInfo>, String> {
|
||||
list_relay_agents_for_state(&state).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_author_queries_prevent_noisy_agent_crowd_out() {
|
||||
let pubkeys = vec!["a".repeat(64), "b".repeat(64)];
|
||||
|
||||
let filters = exact_author_filters(&pubkeys, 10100);
|
||||
|
||||
assert_eq!(filters.len(), 2);
|
||||
for (filter, pubkey) in filters.iter().zip(pubkeys) {
|
||||
assert_eq!(filter["authors"], serde_json::json!([pubkey]));
|
||||
assert_eq!(filter["kinds"], serde_json::json!([10100]));
|
||||
assert_eq!(filter["limit"], 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_policy_queries_are_exact_coordinates() {
|
||||
let candidates = vec!["a".repeat(64), "b".repeat(64)];
|
||||
let owners = std::collections::HashMap::from([
|
||||
(candidates[0].clone(), "c".repeat(64)),
|
||||
(candidates[1].clone(), "d".repeat(64)),
|
||||
]);
|
||||
|
||||
let filters = managed_policy_filters(&candidates, &owners);
|
||||
|
||||
assert_eq!(filters.len(), 2);
|
||||
for (filter, candidate) in filters.iter().zip(candidates) {
|
||||
assert_eq!(filter["authors"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(filter["kinds"], serde_json::json!([30177]));
|
||||
assert_eq!(filter["#d"], serde_json::json!([candidate]));
|
||||
assert_eq!(filter["limit"], 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_filter_batches_do_not_exceed_protocol_limit() {
|
||||
let pubkeys: Vec<_> = (0..25).map(|index| format!("{index:064x}")).collect();
|
||||
let filters = exact_author_filters(&pubkeys, 0);
|
||||
|
||||
let batch_sizes: Vec<_> = filters
|
||||
.chunks(RELAY_FILTER_BATCH_SIZE)
|
||||
.map(<[_]>::len)
|
||||
.collect();
|
||||
|
||||
assert_eq!(batch_sizes, vec![10, 10, 5]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_os = "windows")))]
|
||||
mod real_relay_tests {
|
||||
use super::*;
|
||||
use crate::{app_state::build_app_state, events, managed_agents, relay};
|
||||
use buzz_core_pkg::kind::KIND_MANAGED_AGENT;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn relay_ws_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3037".to_string())
|
||||
}
|
||||
|
||||
fn state_for(keys: Keys) -> AppState {
|
||||
let state = build_app_state();
|
||||
*state.keys.lock().unwrap() = keys;
|
||||
*state.relay_url_override.lock().unwrap() = Some(relay_ws_url());
|
||||
state
|
||||
}
|
||||
|
||||
async fn publish(builder: EventBuilder, signer: &Keys, state: &AppState) {
|
||||
relay::submit_event_with_keys(builder, state, signer, None)
|
||||
.await
|
||||
.expect("publish real-relay fixture");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn newly_retained_managed_policy_replaces_open_access_immediately_on_real_relay() {
|
||||
let owner = Keys::generate();
|
||||
let agent = Keys::generate();
|
||||
let state = state_for(owner.clone());
|
||||
let db_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = db_dir.path().join("retention.sqlite3");
|
||||
let initial_content = serde_json::json!({
|
||||
"name": "Immediate Policy Probe",
|
||||
"parallelism": 1,
|
||||
"respond_to": "anyone"
|
||||
})
|
||||
.to_string();
|
||||
let initial_event =
|
||||
EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), initial_content)
|
||||
.tags([Tag::parse(["d", &agent.public_key().to_hex()]).unwrap()])
|
||||
.custom_created_at(nostr::Timestamp::from(
|
||||
nostr::Timestamp::now().as_secs().saturating_sub(1),
|
||||
));
|
||||
publish(initial_event, &owner, &state).await;
|
||||
|
||||
let updated_content = serde_json::json!({
|
||||
"name": "Immediate Policy Probe",
|
||||
"parallelism": 1,
|
||||
"respond_to": "owner-only"
|
||||
})
|
||||
.to_string();
|
||||
let event = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), updated_content)
|
||||
.tags([Tag::parse(["d", &agent.public_key().to_hex()]).unwrap()])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap();
|
||||
|
||||
{
|
||||
use managed_agents::retention::{open_retention_db, retain_event, RetainedEvent};
|
||||
use nostr::JsonUtil;
|
||||
|
||||
let conn = open_retention_db(&db_path).unwrap();
|
||||
retain_event(
|
||||
&conn,
|
||||
&RetainedEvent {
|
||||
kind: KIND_MANAGED_AGENT,
|
||||
pubkey: owner.public_key().to_hex(),
|
||||
d_tag: agent.public_key().to_hex(),
|
||||
content: event.content.clone(),
|
||||
created_at: event.created_at.as_secs() as i64,
|
||||
raw_event: event.as_json(),
|
||||
pending_sync: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let flushed = managed_agents::persona_events::flush_pending_events_at(
|
||||
&db_path,
|
||||
&state,
|
||||
&relay_ws_url(),
|
||||
&owner,
|
||||
)
|
||||
.await
|
||||
.expect("create-path immediate policy flush");
|
||||
assert_eq!(flushed, 1);
|
||||
|
||||
let queried = query_relay(
|
||||
&state,
|
||||
&[serde_json::json!({
|
||||
"kinds": [KIND_MANAGED_AGENT],
|
||||
"authors": [owner.public_key().to_hex()],
|
||||
"#d": [agent.public_key().to_hex()],
|
||||
"limit": 1
|
||||
})],
|
||||
)
|
||||
.await
|
||||
.expect("query immediately flushed policy");
|
||||
assert_eq!(queried.len(), 1);
|
||||
assert_eq!(queried[0].id, event.id);
|
||||
assert!(queried[0].content.contains("\"respond_to\":\"owner-only\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn cross_identity_managed_agent_is_discovered_and_emits_exact_p_tag_from_real_relay() {
|
||||
let owner = Keys::generate();
|
||||
let viewer = Keys::generate();
|
||||
let agent = Keys::generate();
|
||||
let owner_state = state_for(owner.clone());
|
||||
let viewer_state = state_for(viewer.clone());
|
||||
let channel_id = Uuid::new_v4();
|
||||
|
||||
publish(
|
||||
events::build_create_channel(
|
||||
channel_id,
|
||||
&format!("agent-discovery-e2e-{channel_id}"),
|
||||
"private",
|
||||
"stream",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
&owner,
|
||||
&owner_state,
|
||||
)
|
||||
.await;
|
||||
publish(
|
||||
events::build_add_member(channel_id, &viewer.public_key().to_hex(), None).unwrap(),
|
||||
&owner,
|
||||
&owner_state,
|
||||
)
|
||||
.await;
|
||||
publish(
|
||||
events::build_add_member(channel_id, &agent.public_key().to_hex(), Some("bot"))
|
||||
.unwrap(),
|
||||
&owner,
|
||||
&owner_state,
|
||||
)
|
||||
.await;
|
||||
|
||||
let compat_owner = nostr::Keys::parse(&owner.secret_key().to_secret_hex()).unwrap();
|
||||
let compat_agent = nostr::PublicKey::from_hex(&agent.public_key().to_hex()).unwrap();
|
||||
let auth_tag =
|
||||
buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "").unwrap();
|
||||
relay::sync_managed_agent_profile(
|
||||
&owner_state,
|
||||
&relay_ws_url(),
|
||||
&agent,
|
||||
"Agent Probe",
|
||||
None,
|
||||
Some(&auth_tag),
|
||||
)
|
||||
.await
|
||||
.expect("publish agent kind:0 profile");
|
||||
|
||||
let managed_content = serde_json::json!({
|
||||
"name": "Agent Probe",
|
||||
"parallelism": 1,
|
||||
"respond_to": "anyone"
|
||||
})
|
||||
.to_string();
|
||||
publish(
|
||||
EventBuilder::new(Kind::Custom(30177), managed_content).tags([Tag::parse([
|
||||
"d",
|
||||
&agent.public_key().to_hex(),
|
||||
])
|
||||
.unwrap()]),
|
||||
&owner,
|
||||
&owner_state,
|
||||
)
|
||||
.await;
|
||||
|
||||
let agents = list_relay_agents_for_state(&viewer_state)
|
||||
.await
|
||||
.expect("query production relay directory");
|
||||
assert_eq!(agents.len(), 1, "real relay directory returned {agents:?}");
|
||||
assert_eq!(agents[0].pubkey, agent.public_key().to_hex());
|
||||
assert_eq!(agents[0].name, "Agent Probe");
|
||||
assert_eq!(agents[0].channel_ids, vec![channel_id.to_string()]);
|
||||
|
||||
// Exercise the final protocol boundary, not merely the directory DTO:
|
||||
// selecting this candidate must become the agent's exact lowercase
|
||||
// `p` tag in the signed stream event.
|
||||
let mention_pubkey = agents[0].pubkey.as_str();
|
||||
let signed_message = events::build_message(
|
||||
channel_id,
|
||||
"Ask @Agent Probe to reply",
|
||||
None,
|
||||
&[mention_pubkey],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
&relay_ws_url(),
|
||||
)
|
||||
.unwrap()
|
||||
.sign_with_keys(&viewer)
|
||||
.unwrap();
|
||||
let emitted_mentions: Vec<_> = signed_message
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
let tag = tag.as_slice();
|
||||
(tag.first().map(String::as_str) == Some("p"))
|
||||
.then(|| tag.get(1).cloned())
|
||||
.flatten()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(emitted_mentions, vec![agent.public_key().to_hex()]);
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,8 @@ use crate::{
|
||||
find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents,
|
||||
load_personas, managed_agent_avatar_url, missing_command_message, normalize_agent_args,
|
||||
resolve_command, save_managed_agents, sync_managed_agent_processes, try_regenerate_nest,
|
||||
AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse,
|
||||
DEFAULT_ACP_COMMAND,
|
||||
AgentModelInfo, AgentModelsResponse, ManagedAgentRecord, UpdateManagedAgentRequest,
|
||||
UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND,
|
||||
},
|
||||
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
|
||||
util::now_iso,
|
||||
@@ -697,217 +697,10 @@ use databricks::{
|
||||
};
|
||||
use databricks::{discover_databricks_models, DatabricksAuthIntent};
|
||||
|
||||
/// Update mutable fields on an existing managed agent record.
|
||||
///
|
||||
/// Does NOT auto-restart the agent. Runtime config changes (system prompt,
|
||||
/// parallelism, commands, toolsets) take effect on the next agent spawn.
|
||||
/// Name changes are synced to the relay immediately via a kind:0 re-publish.
|
||||
#[tauri::command]
|
||||
pub async fn update_managed_agent(
|
||||
input: UpdateManagedAgentRequest,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<UpdateManagedAgentResponse, String> {
|
||||
// Phase 1: local save (synchronous, under lock)
|
||||
let (summary, sync_params, rollback) = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (_, exited_pubkeys) =
|
||||
sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app));
|
||||
for pubkey in &exited_pubkeys {
|
||||
state.clear_agent_session_caches(pubkey);
|
||||
}
|
||||
|
||||
let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
|
||||
let previous_record = record.clone();
|
||||
|
||||
let mut name_changed = false;
|
||||
if let Some(name_update) = input.name {
|
||||
let trimmed = name_update.trim().to_string();
|
||||
if !trimmed.is_empty() && trimmed != record.name {
|
||||
record.name = trimmed;
|
||||
name_changed = true;
|
||||
}
|
||||
}
|
||||
apply_model_provider_prompt_update(
|
||||
record,
|
||||
input.model,
|
||||
input.provider,
|
||||
input.system_prompt,
|
||||
)?;
|
||||
if let Some(parallelism) = input.parallelism {
|
||||
record.parallelism = parallelism;
|
||||
}
|
||||
// turn_timeout_seconds is intentionally not applied here —
|
||||
// BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness.
|
||||
// Use idle_timeout_seconds or max_turn_duration_seconds instead.
|
||||
// Store the relay override exactly as supplied (trimmed). An explicit
|
||||
// value pins the agent; empty falls back to the workspace relay at
|
||||
// read-time. A name-only edit (relay_url == None) leaves the pin intact.
|
||||
if let Some(relay_url) = input.relay_url {
|
||||
record.relay_url = relay_url.trim().to_string();
|
||||
}
|
||||
if let Some(acp_command) = input.acp_command {
|
||||
record.acp_command = acp_command;
|
||||
}
|
||||
// Harness edit: the persona's runtime is authoritative, so an explicit
|
||||
// `agent_command_override` is persisted ONLY when the user picks a
|
||||
// command that diverges from the persona, and the empty/whitespace
|
||||
// "Inherit from persona" sentinel clears both the pin and the
|
||||
// materialized record runtime. A name-only edit
|
||||
// (`agent_command == None`) leaves the pin intact. `harness_override`
|
||||
// threads the user's explicit intent — see `apply_agent_command_update`
|
||||
// and `update_time_agent_command_override` for the full resolution
|
||||
// rules.
|
||||
if let Some(agent_command) = input.agent_command {
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
crate::managed_agents::apply_agent_command_update(
|
||||
record,
|
||||
&personas,
|
||||
&agent_command,
|
||||
input.harness_override,
|
||||
);
|
||||
}
|
||||
if let Some(agent_args) = input.agent_args {
|
||||
record.agent_args = agent_args;
|
||||
}
|
||||
// mcp_command is intentionally not applied here — the effective MCP
|
||||
// command is always catalog-derived (known_acp_runtime at spawn time)
|
||||
// and the per-record field is never read by the runtime.
|
||||
if let Some(env_vars) = input.env_vars {
|
||||
crate::managed_agents::validate_user_env_keys(&env_vars)?;
|
||||
record.env_vars = env_vars;
|
||||
}
|
||||
|
||||
// Native provider/model fields are authoritative. Keep the typed marker
|
||||
// derived for new records while retaining legacy typed records for
|
||||
// non-native providers.
|
||||
if record.provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) {
|
||||
let model_ref = record
|
||||
.model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID)
|
||||
.to_string();
|
||||
record.model = Some(model_ref.clone());
|
||||
record.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { model_ref });
|
||||
}
|
||||
|
||||
// Inbound author gate: merge patch onto current values, then validate
|
||||
// the merged state. This lets a single update switch to Allowlist AND
|
||||
// supply pubkeys atomically.
|
||||
let prospective_mode = input.respond_to.unwrap_or(record.respond_to);
|
||||
let prospective_allowlist = match input.respond_to_allowlist.as_ref() {
|
||||
Some(list) => crate::managed_agents::validate_respond_to_allowlist(list)?,
|
||||
None => record.respond_to_allowlist.clone(),
|
||||
};
|
||||
if prospective_mode == crate::managed_agents::RespondTo::Allowlist
|
||||
&& prospective_allowlist.is_empty()
|
||||
{
|
||||
return Err(
|
||||
"respond-to mode 'allowlist' requires at least one pubkey in the allowlist"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
record.respond_to = prospective_mode;
|
||||
// Preserve the persisted allowlist across mode toggles — only replace
|
||||
// when the caller explicitly supplied a new list.
|
||||
if input.respond_to_allowlist.is_some() {
|
||||
record.respond_to_allowlist = prospective_allowlist;
|
||||
}
|
||||
|
||||
record.updated_at = now_iso();
|
||||
|
||||
save_managed_agents(&app, &records)?;
|
||||
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|r| r.pubkey == input.pubkey)
|
||||
.ok_or_else(|| format!("agent {} not found", input.pubkey))?;
|
||||
|
||||
// Publish the edit to the relay. After-save, inside the lock, before
|
||||
// any .await. The retention upsert hashes the opt-IN projection, so an
|
||||
// update that touched only runtime/local fields is a no-op publish.
|
||||
super::agents::retain_managed_agent_pending(&app, &state, record);
|
||||
|
||||
let sync_params = if name_changed {
|
||||
let agent_keys = Keys::parse(&record.private_key_nsec)
|
||||
.map_err(|e| format!("failed to parse agent keys: {e}"))?;
|
||||
// Re-publish the renamed profile to the agent's effective relay:
|
||||
// an explicit per-agent relay wins; empty falls back to workspace.
|
||||
let relay_url = crate::relay::effective_agent_relay_url(
|
||||
&record.relay_url,
|
||||
&relay_ws_url_with_override(&state),
|
||||
);
|
||||
let display_name = record.name.clone();
|
||||
// Avatar fallback derives from the EFFECTIVE harness (persona-wins),
|
||||
// not the frozen snapshot, so an inherited harness picks the right
|
||||
// default avatar.
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
let effective_command = crate::managed_agents::record_agent_command(record, &personas);
|
||||
let avatar_url = record
|
||||
.avatar_url
|
||||
.clone()
|
||||
.or_else(|| managed_agent_avatar_url(&effective_command));
|
||||
let auth_tag = record.auth_tag.clone();
|
||||
Some((agent_keys, relay_url, display_name, avatar_url, auth_tag))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let summary = {
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
build_managed_agent_summary(
|
||||
&app,
|
||||
record,
|
||||
&runtimes,
|
||||
&personas,
|
||||
&crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
|
||||
)?
|
||||
};
|
||||
let rollback = name_changed.then(|| AgentUpdateRollback::new(previous_record, record));
|
||||
(summary, sync_params, rollback)
|
||||
}; // lock dropped here
|
||||
|
||||
try_regenerate_nest(&app);
|
||||
|
||||
// Phase 2: relay profile sync (async, outside lock). A rename is committed
|
||||
// only when this succeeds; otherwise restore the complete pre-edit record
|
||||
// so Desktop and the relay keep one authoritative name.
|
||||
if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params {
|
||||
if let Err(sync_error) = sync_managed_agent_profile(
|
||||
&state,
|
||||
&relay_url,
|
||||
&agent_keys,
|
||||
&display_name,
|
||||
avatar_url.as_deref(),
|
||||
auth_tag.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let rollback = rollback.ok_or_else(|| {
|
||||
"missing local rollback state after relay profile sync failure".to_string()
|
||||
})?;
|
||||
rollback_failed_agent_update(&app, &state, &summary.pubkey, rollback)?;
|
||||
return Err(format!(
|
||||
"Agent rename failed because its relay profile could not be updated. No changes were saved: {sync_error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(UpdateManagedAgentResponse {
|
||||
agent: summary,
|
||||
profile_sync_error: None,
|
||||
})
|
||||
}
|
||||
#[path = "agent_models_update.rs"]
|
||||
mod update;
|
||||
pub use update::update_managed_agent;
|
||||
pub(super) use update::{flush_managed_agent_policy, managed_agent_access_policy_changed};
|
||||
|
||||
// ── Model normalization ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn access_policy_change_requires_runtime_refresh_for_effective_gate_changes() {
|
||||
use crate::managed_agents::RespondTo;
|
||||
|
||||
let allowlist_a = vec!["a".repeat(64)];
|
||||
let allowlist_b = vec!["b".repeat(64)];
|
||||
|
||||
assert!(managed_agent_access_policy_changed(
|
||||
RespondTo::Anyone,
|
||||
&[],
|
||||
RespondTo::OwnerOnly,
|
||||
&[],
|
||||
));
|
||||
assert!(managed_agent_access_policy_changed(
|
||||
RespondTo::Allowlist,
|
||||
&allowlist_a,
|
||||
RespondTo::Allowlist,
|
||||
&allowlist_b,
|
||||
));
|
||||
assert!(!managed_agent_access_policy_changed(
|
||||
RespondTo::OwnerOnly,
|
||||
&allowlist_a,
|
||||
RespondTo::OwnerOnly,
|
||||
&allowlist_b,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_model_normalization_keeps_agent_text_models() {
|
||||
let models = normalize_openai_compatible_models(
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn managed_agent_access_policy_changed(
|
||||
current_mode: crate::managed_agents::RespondTo,
|
||||
current_allowlist: &[String],
|
||||
prospective_mode: crate::managed_agents::RespondTo,
|
||||
prospective_allowlist: &[String],
|
||||
) -> bool {
|
||||
prospective_mode != current_mode
|
||||
|| (prospective_mode == crate::managed_agents::RespondTo::Allowlist
|
||||
&& prospective_allowlist != current_allowlist)
|
||||
}
|
||||
|
||||
fn ensure_access_policy_change_supported(
|
||||
record: &ManagedAgentRecord,
|
||||
access_policy_changed: bool,
|
||||
) -> Result<(), String> {
|
||||
if access_policy_changed
|
||||
&& record.backend != crate::managed_agents::BackendKind::Local
|
||||
&& record.backend_agent_id.is_some()
|
||||
{
|
||||
return Err(
|
||||
"Access cannot be changed while this provider-backed agent is deployed because the provider protocol has no explicit stop or revocation acknowledgement. Stop or recreate the provider agent first."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush a retained managed-agent policy, preserving any earlier profile error.
|
||||
pub(crate) async fn flush_managed_agent_policy(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
existing_error: Option<String>,
|
||||
) -> Option<String> {
|
||||
match crate::managed_agents::persona_events::flush_active_pending_events(app, state).await {
|
||||
Ok(_) => existing_error,
|
||||
Err(error) => Some(match existing_error {
|
||||
Some(profile_error) => {
|
||||
format!("{profile_error}; managed policy sync failed: {error}")
|
||||
}
|
||||
None => format!("managed policy sync failed: {error}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update mutable fields on an existing managed agent record.
|
||||
///
|
||||
/// Most runtime config changes take effect on the next agent spawn. Access
|
||||
/// policy changes stop active local pairs before saving and restart those exact
|
||||
/// pairs after the relay policy is flushed.
|
||||
#[tauri::command]
|
||||
pub async fn update_managed_agent(
|
||||
input: UpdateManagedAgentRequest,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<UpdateManagedAgentResponse, String> {
|
||||
// Phase 1: local save (synchronous, under lock)
|
||||
let (mut summary, sync_params, rollback, access_policy_changed, access_restart_relays) = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (_, exited_pubkeys) =
|
||||
sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app));
|
||||
for pubkey in &exited_pubkeys {
|
||||
state.clear_agent_session_caches(pubkey);
|
||||
}
|
||||
|
||||
let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
|
||||
let previous_record = record.clone();
|
||||
|
||||
let mut name_changed = false;
|
||||
if let Some(name_update) = input.name {
|
||||
let trimmed = name_update.trim().to_string();
|
||||
if !trimmed.is_empty() && trimmed != record.name {
|
||||
record.name = trimmed;
|
||||
name_changed = true;
|
||||
}
|
||||
}
|
||||
apply_model_provider_prompt_update(
|
||||
record,
|
||||
input.model,
|
||||
input.provider,
|
||||
input.system_prompt,
|
||||
)?;
|
||||
if let Some(parallelism) = input.parallelism {
|
||||
record.parallelism = parallelism;
|
||||
}
|
||||
// turn_timeout_seconds is intentionally not applied here —
|
||||
// BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness.
|
||||
// Use idle_timeout_seconds or max_turn_duration_seconds instead.
|
||||
// Store the relay override exactly as supplied (trimmed). An explicit
|
||||
// value pins the agent; empty falls back to the workspace relay at
|
||||
// read-time. A name-only edit (relay_url == None) leaves the pin intact.
|
||||
if let Some(relay_url) = input.relay_url {
|
||||
record.relay_url = relay_url.trim().to_string();
|
||||
}
|
||||
if let Some(acp_command) = input.acp_command {
|
||||
record.acp_command = acp_command;
|
||||
}
|
||||
// Harness edit: the persona's runtime is authoritative, so an explicit
|
||||
// `agent_command_override` is persisted ONLY when the user picks a
|
||||
// command that diverges from the persona, and the empty/whitespace
|
||||
// "Inherit from persona" sentinel clears both the pin and the
|
||||
// materialized record runtime. A name-only edit
|
||||
// (`agent_command == None`) leaves the pin intact. `harness_override`
|
||||
// threads the user's explicit intent — see `apply_agent_command_update`
|
||||
// and `update_time_agent_command_override` for the full resolution
|
||||
// rules.
|
||||
if let Some(agent_command) = input.agent_command {
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
crate::managed_agents::apply_agent_command_update(
|
||||
record,
|
||||
&personas,
|
||||
&agent_command,
|
||||
input.harness_override,
|
||||
);
|
||||
}
|
||||
if let Some(agent_args) = input.agent_args {
|
||||
record.agent_args = agent_args;
|
||||
}
|
||||
// mcp_command is intentionally not applied here — the effective MCP
|
||||
// command is always catalog-derived (known_acp_runtime at spawn time)
|
||||
// and the per-record field is never read by the runtime.
|
||||
if let Some(env_vars) = input.env_vars {
|
||||
crate::managed_agents::validate_user_env_keys(&env_vars)?;
|
||||
record.env_vars = env_vars;
|
||||
}
|
||||
|
||||
// Native provider/model fields are authoritative. Keep the typed marker
|
||||
// derived for new records while retaining legacy typed records for
|
||||
// non-native providers.
|
||||
if record.provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) {
|
||||
let model_ref = record
|
||||
.model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID)
|
||||
.to_string();
|
||||
record.model = Some(model_ref.clone());
|
||||
record.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { model_ref });
|
||||
}
|
||||
|
||||
// Inbound author gate: merge patch onto current values, then validate
|
||||
// the merged state. This lets a single update switch to Allowlist AND
|
||||
// supply pubkeys atomically.
|
||||
let prospective_mode = input.respond_to.unwrap_or(record.respond_to);
|
||||
let prospective_allowlist = match input.respond_to_allowlist.as_ref() {
|
||||
Some(list) => crate::managed_agents::validate_respond_to_allowlist(list)?,
|
||||
None => record.respond_to_allowlist.clone(),
|
||||
};
|
||||
if prospective_mode == crate::managed_agents::RespondTo::Allowlist
|
||||
&& prospective_allowlist.is_empty()
|
||||
{
|
||||
return Err(
|
||||
"respond-to mode 'allowlist' requires at least one pubkey in the allowlist"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let access_policy_changed = managed_agent_access_policy_changed(
|
||||
record.respond_to,
|
||||
&record.respond_to_allowlist,
|
||||
prospective_mode,
|
||||
&prospective_allowlist,
|
||||
);
|
||||
ensure_access_policy_change_supported(record, access_policy_changed)?;
|
||||
|
||||
// Revoke the currently running local gate before persisting or
|
||||
// advertising the replacement policy. Keeping this inside the same
|
||||
// store/process critical section prevents another command or a status
|
||||
// refresh from observing a saved narrow policy while the old broad
|
||||
// process is still alive. A stop failure aborts before mutation.
|
||||
let mut access_restart_relays = Vec::new();
|
||||
if access_policy_changed && record.backend == crate::managed_agents::BackendKind::Local {
|
||||
access_restart_relays =
|
||||
crate::managed_agents::managed_agent_runtime_keys(&runtimes, &record.pubkey)
|
||||
.into_iter()
|
||||
.map(|key| key.relay_url)
|
||||
.collect();
|
||||
if access_restart_relays.is_empty() && record.runtime_pid.is_some() {
|
||||
access_restart_relays.push(crate::relay::effective_agent_relay_url(
|
||||
&record.relay_url,
|
||||
&relay_ws_url_with_override(&state),
|
||||
));
|
||||
}
|
||||
if !access_restart_relays.is_empty() {
|
||||
crate::managed_agents::stop_managed_agent_process(&app, record, &mut runtimes)?;
|
||||
}
|
||||
}
|
||||
|
||||
record.respond_to = prospective_mode;
|
||||
// Preserve the persisted allowlist across mode toggles — only replace
|
||||
// when the caller explicitly supplied a new list.
|
||||
if input.respond_to_allowlist.is_some() {
|
||||
record.respond_to_allowlist = prospective_allowlist;
|
||||
}
|
||||
|
||||
record.updated_at = now_iso();
|
||||
|
||||
save_managed_agents(&app, &records)?;
|
||||
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|r| r.pubkey == input.pubkey)
|
||||
.ok_or_else(|| format!("agent {} not found", input.pubkey))?;
|
||||
|
||||
// Publish the edit to the relay. After-save, inside the lock, before
|
||||
// any .await. The retention upsert hashes the opt-IN projection, so an
|
||||
// update that touched only runtime/local fields is a no-op publish.
|
||||
super::super::agents::retain_managed_agent_pending(&app, &state, record);
|
||||
|
||||
let sync_params = if name_changed {
|
||||
let agent_keys = Keys::parse(&record.private_key_nsec)
|
||||
.map_err(|e| format!("failed to parse agent keys: {e}"))?;
|
||||
// Re-publish the renamed profile to the agent's effective relay:
|
||||
// an explicit per-agent relay wins; empty falls back to workspace.
|
||||
let relay_url = crate::relay::effective_agent_relay_url(
|
||||
&record.relay_url,
|
||||
&relay_ws_url_with_override(&state),
|
||||
);
|
||||
let display_name = record.name.clone();
|
||||
// Avatar fallback derives from the EFFECTIVE harness (persona-wins),
|
||||
// not the frozen snapshot, so an inherited harness picks the right
|
||||
// default avatar.
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
let effective_command = crate::managed_agents::record_agent_command(record, &personas);
|
||||
let avatar_url = record
|
||||
.avatar_url
|
||||
.clone()
|
||||
.or_else(|| managed_agent_avatar_url(&effective_command));
|
||||
let auth_tag = record.auth_tag.clone();
|
||||
Some((agent_keys, relay_url, display_name, avatar_url, auth_tag))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let summary = {
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
build_managed_agent_summary(
|
||||
&app,
|
||||
record,
|
||||
&runtimes,
|
||||
&personas,
|
||||
&crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
|
||||
)?
|
||||
};
|
||||
let rollback = name_changed
|
||||
.then(|| AgentUpdateRollback::new(previous_record, record, access_policy_changed));
|
||||
(
|
||||
summary,
|
||||
sync_params,
|
||||
rollback,
|
||||
access_policy_changed,
|
||||
access_restart_relays,
|
||||
)
|
||||
}; // lock dropped here
|
||||
|
||||
try_regenerate_nest(&app);
|
||||
|
||||
// Phase 2: relay sync (async, outside lock). The owner-signed managed
|
||||
// policy is security-sensitive: an access reduction must replace the old
|
||||
// relay head before this command returns rather than waiting for the
|
||||
// 30-second retention sweep. The flush remains durable/best-effort; rows a
|
||||
// relay does not accept stay pending for the background retry.
|
||||
let mut profile_sync_error =
|
||||
crate::managed_agents::persona_events::flush_active_pending_events(&app, &state)
|
||||
.await
|
||||
.err()
|
||||
.map(|error| format!("managed policy sync failed: {error}"));
|
||||
if profile_sync_error.is_none()
|
||||
&& crate::managed_agents::persona_events::active_pending_event(
|
||||
&app,
|
||||
&state,
|
||||
buzz_core_pkg::kind::KIND_MANAGED_AGENT,
|
||||
&summary.pubkey,
|
||||
)?
|
||||
{
|
||||
profile_sync_error = Some(
|
||||
"managed policy sync failed: relay did not accept the updated policy; retry queued"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// A rename is committed only when profile sync succeeds; otherwise restore
|
||||
// the complete pre-edit record so Desktop and the relay keep one
|
||||
// authoritative name.
|
||||
if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params {
|
||||
if let Err(sync_error) = sync_managed_agent_profile(
|
||||
&state,
|
||||
&relay_url,
|
||||
&agent_keys,
|
||||
&display_name,
|
||||
avatar_url.as_deref(),
|
||||
auth_tag.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let rollback = rollback.ok_or_else(|| {
|
||||
"missing local rollback state after relay profile sync failure".to_string()
|
||||
})?;
|
||||
rollback_failed_agent_update(&app, &state, &summary.pubkey, rollback)?;
|
||||
let restart_suffix = if access_restart_relays.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
match super::super::agents::start_local_agent_pairs_with_preflight(
|
||||
&app,
|
||||
&state,
|
||||
&summary.pubkey,
|
||||
&access_restart_relays,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => String::new(),
|
||||
Err(error) => format!(
|
||||
" The runtime also failed to restart with the kept access policy: {error}"
|
||||
),
|
||||
}
|
||||
};
|
||||
let rollback_message = if access_policy_changed {
|
||||
"The access policy change was kept, but other edits were rolled back"
|
||||
} else {
|
||||
"No changes were saved"
|
||||
};
|
||||
return Err(format!(
|
||||
"Agent rename failed because its relay profile could not be updated. {rollback_message}: {sync_error}.{restart_suffix}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !access_restart_relays.is_empty() {
|
||||
summary = super::super::agents::start_local_agent_pairs_with_preflight(
|
||||
&app,
|
||||
&state,
|
||||
&summary.pubkey,
|
||||
&access_restart_relays,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Agent access was saved and published, but its runtime failed to restart with the new policy: {error}"
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(UpdateManagedAgentResponse {
|
||||
agent: summary,
|
||||
profile_sync_error: profile_sync_error.take(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "agent_models_update_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,31 @@
|
||||
use super::*;
|
||||
|
||||
fn provider_record(deployed: bool) -> ManagedAgentRecord {
|
||||
let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({
|
||||
"pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "",
|
||||
"agent_command": "", "agent_args": [], "mcp_command": "",
|
||||
"turn_timeout_seconds": 0, "system_prompt": null, "created_at": "",
|
||||
"updated_at": "", "last_started_at": null, "last_stopped_at": null,
|
||||
"last_exit_code": null, "last_error": null
|
||||
}))
|
||||
.unwrap();
|
||||
record.backend = crate::managed_agents::BackendKind::Provider {
|
||||
id: "provider".into(),
|
||||
config: serde_json::json!({}),
|
||||
};
|
||||
record.backend_agent_id = deployed.then(|| "deployment".to_string());
|
||||
record
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deployed_provider_rejects_access_edits_that_cannot_be_revoked() {
|
||||
let error = ensure_access_policy_change_supported(&provider_record(true), true)
|
||||
.expect_err("deployed provider access edit must fail closed");
|
||||
assert!(error.contains("no explicit stop or revocation acknowledgement"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undeployed_provider_accepts_access_edits() {
|
||||
ensure_access_policy_change_supported(&provider_record(false), true)
|
||||
.expect("no running provider deployment can retain stale access");
|
||||
}
|
||||
@@ -11,13 +11,19 @@ use crate::{
|
||||
pub(super) struct AgentUpdateRollback {
|
||||
attempted_record: ManagedAgentRecord,
|
||||
previous_record: ManagedAgentRecord,
|
||||
preserve_access_policy: bool,
|
||||
}
|
||||
|
||||
impl AgentUpdateRollback {
|
||||
pub(super) fn new(previous_record: ManagedAgentRecord, attempted: &ManagedAgentRecord) -> Self {
|
||||
pub(super) fn new(
|
||||
previous_record: ManagedAgentRecord,
|
||||
attempted: &ManagedAgentRecord,
|
||||
preserve_access_policy: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
attempted_record: attempted.clone(),
|
||||
previous_record,
|
||||
preserve_access_policy,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +70,13 @@ fn restore_agent_update(
|
||||
attempted_with_current_runtime != rollback.attempted_record
|
||||
};
|
||||
let mut restored = rollback.previous_record;
|
||||
if rollback.preserve_access_policy {
|
||||
restored.respond_to = current.respond_to;
|
||||
restored
|
||||
.respond_to_allowlist
|
||||
.clone_from(¤t.respond_to_allowlist);
|
||||
restored.updated_at.clone_from(¤t.updated_at);
|
||||
}
|
||||
copy_runtime_state(current, &mut restored);
|
||||
if runtime_changed {
|
||||
restored.updated_at.clone_from(¤t.updated_at);
|
||||
@@ -137,7 +150,7 @@ mod tests {
|
||||
attempted.name = "New name".to_string();
|
||||
attempted.model = Some("new-model".to_string());
|
||||
attempted.updated_at = "attempt".to_string();
|
||||
let rollback = AgentUpdateRollback::new(previous, &attempted);
|
||||
let rollback = AgentUpdateRollback::new(previous, &attempted, false);
|
||||
let mut records = vec![attempted];
|
||||
|
||||
restore_agent_update(&mut records, "abcd1234", rollback)
|
||||
@@ -148,13 +161,34 @@ mod tests {
|
||||
assert_eq!(records[0].updated_at, "before");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_profile_sync_keeps_a_tightened_access_policy() {
|
||||
let previous = record("Old name", "before");
|
||||
let mut attempted = previous.clone();
|
||||
attempted.name = "New name".to_string();
|
||||
attempted.respond_to = crate::managed_agents::RespondTo::OwnerOnly;
|
||||
attempted.updated_at = "attempt".to_string();
|
||||
let rollback = AgentUpdateRollback::new(previous, &attempted, true);
|
||||
let mut records = vec![attempted];
|
||||
|
||||
restore_agent_update(&mut records, "abcd1234", rollback)
|
||||
.expect("matching attempted update rolls back non-access fields");
|
||||
|
||||
assert_eq!(records[0].name, "Old name");
|
||||
assert_eq!(
|
||||
records[0].respond_to,
|
||||
crate::managed_agents::RespondTo::OwnerOnly
|
||||
);
|
||||
assert_eq!(records[0].updated_at, "attempt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_profile_sync_does_not_overwrite_a_newer_agent_update() {
|
||||
let previous = record("Old name", "before");
|
||||
let mut attempted = previous.clone();
|
||||
attempted.name = "New name".to_string();
|
||||
attempted.updated_at = "attempt".to_string();
|
||||
let rollback = AgentUpdateRollback::new(previous, &attempted);
|
||||
let rollback = AgentUpdateRollback::new(previous, &attempted, false);
|
||||
let mut newer = attempted;
|
||||
newer.name = "Newest name".to_string();
|
||||
newer.updated_at = "newer".to_string();
|
||||
@@ -175,7 +209,7 @@ mod tests {
|
||||
attempted.name = "New name".to_string();
|
||||
attempted.model = Some("new-model".to_string());
|
||||
attempted.updated_at = "attempt".to_string();
|
||||
let rollback = AgentUpdateRollback::new(previous, &attempted);
|
||||
let rollback = AgentUpdateRollback::new(previous, &attempted, false);
|
||||
let mut churned = attempted;
|
||||
churned.runtime_pid = None;
|
||||
churned.last_stopped_at = Some("stopped".to_string());
|
||||
|
||||
@@ -6,15 +6,14 @@ use super::managed_agent_definition::validate_create_definition;
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
build_managed_agent_summary, current_instance_id, discover_provider_candidates,
|
||||
ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas,
|
||||
load_teams, managed_agent_avatar_url, normalize_agent_args, provider_deploy,
|
||||
resolve_provider_binary, save_managed_agents, start_managed_agent_process,
|
||||
stop_managed_agent_process, stop_managed_agent_workspace_pair,
|
||||
sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind,
|
||||
CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord,
|
||||
ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM,
|
||||
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
build_managed_agent_summary, current_instance_id, ensure_persona_is_active,
|
||||
find_managed_agent_mut, load_managed_agents, load_personas, load_teams,
|
||||
managed_agent_avatar_url, normalize_agent_args, resolve_provider_binary,
|
||||
save_managed_agents, start_managed_agent_process, stop_managed_agent_process,
|
||||
stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest,
|
||||
validate_provider_config, BackendKind, CreateManagedAgentRequest,
|
||||
CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig,
|
||||
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
},
|
||||
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
|
||||
util::now_iso,
|
||||
@@ -441,72 +440,7 @@ pub(super) async fn start_local_agent_with_preflight(
|
||||
)
|
||||
}
|
||||
|
||||
/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via
|
||||
/// spawn_blocking, and persists the result (backend_agent_id or last_error).
|
||||
///
|
||||
/// Idempotency: calling deploy on an already-deployed agent sends the same payload
|
||||
/// again. Providers are expected to handle this as an update-in-place or no-op —
|
||||
/// the protocol does not include an explicit `undeploy` operation (deferred to v2).
|
||||
///
|
||||
/// Returns Ok(()) on success, Err(message) on failure. Either way the record is
|
||||
/// updated and saved before returning.
|
||||
async fn deploy_to_provider(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
provider_id: &str,
|
||||
config: &serde_json::Value,
|
||||
agent_json: serde_json::Value,
|
||||
cached_binary_path: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
// Resolve via discovered candidates only. Cached path must match BOTH
|
||||
// "is a discovered candidate" AND "belongs to this provider_id". A tampered
|
||||
// record cannot redirect deploys to a different provider's binary.
|
||||
let bin_path = cached_binary_path
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| p.exists())
|
||||
.map(|p| p.canonicalize().unwrap_or(p))
|
||||
.filter(|canonical| {
|
||||
discover_provider_candidates().iter().any(|(id, cp)| {
|
||||
id == provider_id && cp.canonicalize().ok().as_ref() == Some(canonical)
|
||||
})
|
||||
})
|
||||
.map_or_else(|| resolve_provider_binary(provider_id), Ok)?;
|
||||
|
||||
let config_clone = config.clone();
|
||||
let deploy_result =
|
||||
tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone))
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?;
|
||||
|
||||
// Persist result under lock.
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut records = load_managed_agents(app)?;
|
||||
let rec = records
|
||||
.iter_mut()
|
||||
.find(|r| r.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
|
||||
match deploy_result {
|
||||
Ok(backend_agent_id) => {
|
||||
rec.backend_agent_id = Some(backend_agent_id);
|
||||
rec.last_started_at = Some(now_iso());
|
||||
rec.updated_at = now_iso();
|
||||
rec.last_error = None;
|
||||
}
|
||||
Err(ref e) => {
|
||||
rec.last_error = Some(e.clone());
|
||||
rec.updated_at = now_iso();
|
||||
save_managed_agents(app, &records)?;
|
||||
return Err(e.clone());
|
||||
}
|
||||
}
|
||||
save_managed_agents(app, &records)?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) use provider_deploy::deploy_to_provider;
|
||||
|
||||
// Async so the blocking body (disk reads of agent/persona records, per-agent
|
||||
// process-liveness syscalls, and a possible save) runs on Tauri's worker pool
|
||||
@@ -870,6 +804,7 @@ pub async fn create_managed_agent(
|
||||
runtime_pid: None,
|
||||
backend: input.backend.clone(),
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path,
|
||||
persona_team_dir: None,
|
||||
persona_name_in_team: None,
|
||||
@@ -979,7 +914,7 @@ pub async fn create_managed_agent(
|
||||
&resolved_relay_url,
|
||||
&relay_ws_url_with_override(&state),
|
||||
);
|
||||
let profile_sync_error = (sync_managed_agent_profile(
|
||||
let mut profile_sync_error = (sync_managed_agent_profile(
|
||||
&state,
|
||||
&profile_relay_url,
|
||||
&agent_keys,
|
||||
@@ -989,12 +924,11 @@ pub async fn create_managed_agent(
|
||||
)
|
||||
.await)
|
||||
.err();
|
||||
profile_sync_error =
|
||||
super::agent_models::flush_managed_agent_policy(&app, &state, profile_sync_error).await;
|
||||
|
||||
// ── Phase 5: provider deploy (async, outside lock) ───────────────────────
|
||||
let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local {
|
||||
if let BackendKind::Provider { ref id, ref config } = input.backend {
|
||||
// Read the saved record to build the deploy payload (record has the
|
||||
// canonical field values after Phase 3 normalization).
|
||||
let agent_json = {
|
||||
let _g = state
|
||||
.managed_agents_store_lock
|
||||
@@ -1354,7 +1288,8 @@ pub async fn delete_managed_agent(
|
||||
#[path = "agents_deploy.rs"]
|
||||
mod deploy;
|
||||
pub(super) mod provider_access;
|
||||
use deploy::build_deploy_payload;
|
||||
mod provider_deploy;
|
||||
pub(super) use deploy::build_deploy_payload;
|
||||
#[cfg(test)]
|
||||
use deploy::{deploy_payload_json, DeployProjections};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -15,7 +15,9 @@ pub(super) fn needs_reconciliation_with_policy(
|
||||
record: &ManagedAgentRecord,
|
||||
owner_only_access: bool,
|
||||
) -> bool {
|
||||
owner_only_access && record.backend != BackendKind::Local && record.backend_agent_id.is_some()
|
||||
(owner_only_access || record.provider_policy_pending)
|
||||
&& record.backend != BackendKind::Local
|
||||
&& record.backend_agent_id.is_some()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -50,25 +52,23 @@ fn collect_targets_with(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Redeploy every existing provider agent in an owner-only access build.
|
||||
/// Redeploy existing provider agents whose access policy requires enforcement.
|
||||
///
|
||||
/// The saved `backend_agent_id` only proves that some provider deployment
|
||||
/// exists. A marked build sends the current owner-only payload before each
|
||||
/// community UI load. Workspace apply fails closed if any provider rejects it.
|
||||
/// Owner-only builds refresh every existing deployment before each community UI
|
||||
/// load. All builds also retry records whose saved policy has not yet been
|
||||
/// acknowledged by a successful provider deployment. Workspace apply fails
|
||||
/// closed if any selected provider rejects the current policy.
|
||||
pub(crate) async fn reconcile_on_workspace_apply(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
) -> Result<(), String> {
|
||||
if !crate::managed_agents::owner_only_access_build() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let owner_only_access = crate::managed_agents::owner_only_access_build();
|
||||
let targets = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
collect_targets_with(load_managed_agents(app)?, true, |record| {
|
||||
collect_targets_with(load_managed_agents(app)?, owner_only_access, |record| {
|
||||
super::build_deploy_payload(app, state, record)
|
||||
})
|
||||
};
|
||||
@@ -110,7 +110,7 @@ pub(crate) async fn reconcile_on_workspace_apply(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_failure(
|
||||
pub(crate) fn persist_failure(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
@@ -180,17 +180,53 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmarked_build_collects_no_upgrade_targets() {
|
||||
let records = vec![record(
|
||||
fn unmarked_build_collects_only_pending_targets() {
|
||||
let mut pending = record(
|
||||
BackendKind::Provider {
|
||||
id: "pending-provider".into(),
|
||||
config: serde_json::json!({}),
|
||||
},
|
||||
Some("existing-pending"),
|
||||
);
|
||||
pending.pubkey = "pending-agent".into();
|
||||
pending.provider_policy_pending = true;
|
||||
let ordinary = record(
|
||||
BackendKind::Provider {
|
||||
id: "ordinary-provider".into(),
|
||||
config: serde_json::json!({}),
|
||||
},
|
||||
Some("existing-ordinary"),
|
||||
);
|
||||
|
||||
let targets = collect_targets_with(vec![ordinary, pending], false, |record| {
|
||||
Ok(serde_json::json!({"pubkey": record.pubkey}))
|
||||
});
|
||||
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].pubkey, "pending-agent");
|
||||
assert_eq!(targets[0].provider_id, "pending-provider");
|
||||
assert_eq!(
|
||||
targets[0].agent_json.as_ref().unwrap()["pubkey"],
|
||||
"pending-agent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_policy_requires_an_existing_provider_deployment() {
|
||||
let mut undeployed = record(
|
||||
BackendKind::Provider {
|
||||
id: "provider".into(),
|
||||
config: serde_json::json!({}),
|
||||
},
|
||||
Some("existing"),
|
||||
)];
|
||||
|
||||
assert!(
|
||||
collect_targets_with(records, false, |_| { Ok(serde_json::Value::Null) }).is_empty()
|
||||
None,
|
||||
);
|
||||
undeployed.provider_policy_pending = true;
|
||||
let mut local = record(BackendKind::Local, Some("stale-provider-id"));
|
||||
local.provider_policy_pending = true;
|
||||
|
||||
assert!(collect_targets_with(vec![undeployed, local], false, |_| {
|
||||
Ok(serde_json::Value::Null)
|
||||
})
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
discover_provider_candidates, load_managed_agents, provider_deploy,
|
||||
resolve_provider_binary, save_managed_agents, BackendKind,
|
||||
},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
use super::build_deploy_payload;
|
||||
|
||||
/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via
|
||||
/// spawn_blocking, and persists the result (backend_agent_id or last_error).
|
||||
///
|
||||
/// Idempotency: calling deploy on an already-deployed agent sends the same payload
|
||||
/// again. Providers are expected to handle this as an update-in-place or no-op.
|
||||
/// The protocol has no explicit `undeploy` operation or acknowledgement that an
|
||||
/// existing process stopped, so a successful redeploy delegates access-policy
|
||||
/// revocation semantics to the provider implementation (deferred to v2).
|
||||
/// Returns Ok(()) on success, Err(message) on failure. Either way the record is
|
||||
/// updated and saved before returning.
|
||||
pub(crate) async fn deploy_to_provider(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
_provider_id: &str,
|
||||
_config: &serde_json::Value,
|
||||
_agent_json: serde_json::Value,
|
||||
_cached_binary_path: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let deploy_lock = {
|
||||
let mut locks = state
|
||||
.provider_deploy_locks
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
Arc::clone(
|
||||
locks
|
||||
.entry(pubkey.to_string())
|
||||
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
|
||||
)
|
||||
};
|
||||
let _deploy_guard = deploy_lock.lock().await;
|
||||
// The payload may have waited behind another deployment. Rebuild it from
|
||||
// the current record so the final provider invocation always carries the
|
||||
// newest saved policy rather than the stale snapshot captured by its caller.
|
||||
let (provider_id, config, cached_binary_path, agent_json) = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let records = load_managed_agents(app)?;
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
let (provider_id, config) = match &record.backend {
|
||||
BackendKind::Provider { id, config } => (id.clone(), config.clone()),
|
||||
BackendKind::Local => return Err(format!("agent {pubkey} is not provider-backed")),
|
||||
};
|
||||
(
|
||||
provider_id,
|
||||
config,
|
||||
record.provider_binary_path.clone(),
|
||||
build_deploy_payload(app, state, record)?,
|
||||
)
|
||||
};
|
||||
// Resolve via discovered candidates only. Cached path must match BOTH
|
||||
// "is a discovered candidate" AND "belongs to this provider_id". A tampered
|
||||
// record cannot redirect deploys to a different provider's binary.
|
||||
let bin_path = cached_binary_path
|
||||
.as_deref()
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| p.exists())
|
||||
.map(|p| p.canonicalize().unwrap_or(p))
|
||||
.filter(|canonical| {
|
||||
discover_provider_candidates().iter().any(|(id, cp)| {
|
||||
id == &provider_id && cp.canonicalize().ok().as_ref() == Some(canonical)
|
||||
})
|
||||
})
|
||||
.map_or_else(|| resolve_provider_binary(&provider_id), Ok)?;
|
||||
|
||||
let deployed_agent_json = agent_json.clone();
|
||||
let config_clone = config.clone();
|
||||
let deploy_result =
|
||||
tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone))
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?;
|
||||
|
||||
// Persist result under lock.
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut records = load_managed_agents(app)?;
|
||||
let rec = records
|
||||
.iter_mut()
|
||||
.find(|r| r.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
|
||||
let result = apply_deploy_result(rec, deploy_result, &deployed_agent_json);
|
||||
save_managed_agents(app, &records)?;
|
||||
result
|
||||
}
|
||||
|
||||
fn policy_matches_payload(
|
||||
record: &crate::managed_agents::ManagedAgentRecord,
|
||||
deployed_agent_json: &serde_json::Value,
|
||||
) -> bool {
|
||||
deployed_agent_json
|
||||
.get("respond_to")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(record.respond_to.as_str())
|
||||
&& deployed_agent_json.get("respond_to_allowlist")
|
||||
== Some(&serde_json::json!(record.respond_to_allowlist))
|
||||
}
|
||||
|
||||
fn apply_deploy_result(
|
||||
record: &mut crate::managed_agents::ManagedAgentRecord,
|
||||
deploy_result: Result<String, String>,
|
||||
deployed_agent_json: &serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
match deploy_result {
|
||||
Ok(backend_agent_id) => {
|
||||
record.backend_agent_id = Some(backend_agent_id);
|
||||
if policy_matches_payload(record, deployed_agent_json) {
|
||||
record.provider_policy_pending = false;
|
||||
}
|
||||
record.last_started_at = Some(now_iso());
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = None;
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
record.last_error = Some(error.clone());
|
||||
record.updated_at = now_iso();
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn record() -> crate::managed_agents::ManagedAgentRecord {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "",
|
||||
"agent_command": "", "agent_args": [], "mcp_command": "",
|
||||
"turn_timeout_seconds": 0, "system_prompt": null, "created_at": "",
|
||||
"updated_at": "", "last_started_at": null, "last_stopped_at": null,
|
||||
"last_exit_code": null, "last_error": null,
|
||||
"provider_policy_pending": true
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn policy_payload(respond_to: &str) -> serde_json::Value {
|
||||
serde_json::json!({"respond_to": respond_to, "respond_to_allowlist": []})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_deploy_acknowledges_pending_policy() {
|
||||
let mut record = record();
|
||||
|
||||
apply_deploy_result(
|
||||
&mut record,
|
||||
Ok("provider-agent".into()),
|
||||
&policy_payload("owner-only"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!record.provider_policy_pending);
|
||||
assert_eq!(record.backend_agent_id.as_deref(), Some("provider-agent"));
|
||||
assert_eq!(record.last_error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_stale_deploy_preserves_newer_pending_policy() {
|
||||
let mut record = record();
|
||||
record.respond_to = crate::managed_agents::RespondTo::Anyone;
|
||||
|
||||
apply_deploy_result(
|
||||
&mut record,
|
||||
Ok("provider-agent".into()),
|
||||
&policy_payload("owner-only"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(record.provider_policy_pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_deploy_preserves_pending_policy() {
|
||||
let mut record = record();
|
||||
|
||||
let error = apply_deploy_result(
|
||||
&mut record,
|
||||
Err("provider unavailable".into()),
|
||||
&policy_payload("owner-only"),
|
||||
)
|
||||
.expect_err("deployment should fail");
|
||||
|
||||
assert_eq!(error, "provider unavailable");
|
||||
assert!(record.provider_policy_pending);
|
||||
assert_eq!(record.last_error.as_deref(), Some("provider unavailable"));
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ pub(super) fn ensure_remote_provider_supported(provider: Option<&str>) -> Result
|
||||
}
|
||||
|
||||
/// Build the standard agent JSON payload for provider deploy calls.
|
||||
pub(super) fn build_deploy_payload(
|
||||
pub(crate) fn build_deploy_payload(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
record: &ManagedAgentRecord,
|
||||
|
||||
@@ -34,6 +34,7 @@ fn bare_agent_record(
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
@@ -625,6 +626,11 @@ fn provider_upgrade_reconciliation_targets_existing_deployments_only_in_marked_b
|
||||
&record, false
|
||||
));
|
||||
|
||||
record.provider_policy_pending = true;
|
||||
assert!(provider_access::needs_reconciliation_with_policy(
|
||||
&record, false
|
||||
));
|
||||
|
||||
record.backend_agent_id = None;
|
||||
assert!(!provider_access::needs_reconciliation_with_policy(
|
||||
&record, true
|
||||
|
||||
@@ -42,6 +42,7 @@ fn make_agent(
|
||||
runtime_pid,
|
||||
backend: BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -17,6 +17,21 @@ use crate::{
|
||||
#[cfg(test)]
|
||||
mod inbound_tests;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum InboundRuntimeRefresh {
|
||||
Local {
|
||||
pubkey: String,
|
||||
relay_urls: Vec<String>,
|
||||
},
|
||||
Provider {
|
||||
pubkey: String,
|
||||
provider_id: String,
|
||||
config: serde_json::Value,
|
||||
cached_binary_path: Option<String>,
|
||||
agent_json: Result<serde_json::Value, String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Apply an inbound kind:30175 persona event from the relay onto the local
|
||||
/// store. The frontend's live subscription invokes this per event for our own
|
||||
/// authored coordinate so Device B inherits Device A's edits.
|
||||
@@ -57,23 +72,84 @@ pub async fn reconcile_inbound_persona_event(
|
||||
arrival_relay_url: String,
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
reconcile_inbound_persona_event_blocking(event_json, arrival_relay_url, app)
|
||||
let blocking_app = app.clone();
|
||||
let restart = tokio::task::spawn_blocking(move || {
|
||||
reconcile_inbound_persona_event_blocking(event_json, arrival_relay_url, blocking_app)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
|
||||
|
||||
match restart {
|
||||
Some(InboundRuntimeRefresh::Local { pubkey, relay_urls }) => {
|
||||
let state = app.state::<AppState>();
|
||||
super::super::agents::start_local_agent_pairs_with_preflight(
|
||||
&app,
|
||||
&state,
|
||||
&pubkey,
|
||||
&relay_urls,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Inbound agent access was saved, but its runtime failed to restart with the new policy: {error}"
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Some(InboundRuntimeRefresh::Provider {
|
||||
pubkey,
|
||||
provider_id,
|
||||
config,
|
||||
cached_binary_path,
|
||||
agent_json,
|
||||
}) => {
|
||||
let state = app.state::<AppState>();
|
||||
let agent_json = match agent_json {
|
||||
Ok(agent_json) => agent_json,
|
||||
Err(error) => {
|
||||
let message = format!(
|
||||
"Inbound agent access was saved, but its provider deployment could not be refreshed safely: {error}"
|
||||
);
|
||||
super::super::agents::provider_access::persist_failure(
|
||||
&app, &state, &pubkey, &message,
|
||||
)?;
|
||||
let _ = app.emit("agents-data-changed", ());
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
super::super::agents::deploy_to_provider(
|
||||
&app,
|
||||
&state,
|
||||
&pubkey,
|
||||
&provider_id,
|
||||
&config,
|
||||
agent_json,
|
||||
cached_binary_path.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Inbound agent access was saved, but its provider deployment failed to refresh with the new policy: {error}"
|
||||
)
|
||||
})?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reconcile_inbound_persona_event_blocking(
|
||||
event_json: String,
|
||||
arrival_relay_url: String,
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<Option<InboundRuntimeRefresh>, String> {
|
||||
use crate::managed_agents::{
|
||||
agent_events::managed_agent_content_from_event,
|
||||
load_managed_agents, load_teams,
|
||||
persona_events::persona_from_event,
|
||||
retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent},
|
||||
retention::{
|
||||
inbound_event_outcome, open_retention_db, retain_inbound_event, InboundOutcome,
|
||||
RetainedEvent,
|
||||
},
|
||||
save_managed_agents, save_teams,
|
||||
team_events::team_content_from_event,
|
||||
};
|
||||
@@ -93,11 +169,12 @@ fn reconcile_inbound_persona_event_blocking(
|
||||
// in its `a` tag (`<target_kind>:<owner>:<d_tag>`). Handled before the
|
||||
// upsert dispatch because its coordinate and retention key differ.
|
||||
if kind == KIND_DELETION {
|
||||
return reconcile_inbound_tombstone(&event, &arrival_relay_url, &app, &state);
|
||||
reconcile_inbound_tombstone(&event, &arrival_relay_url, &app, &state)?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// The d-tag identifies the record within its kind. Persona derives it from
|
||||
@@ -137,25 +214,35 @@ fn reconcile_inbound_persona_event_blocking(
|
||||
&arrival_relay_url,
|
||||
)?
|
||||
else {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
};
|
||||
let conn = open_retention_db(&scope.db_path)?;
|
||||
let outcome = retain_inbound_event(
|
||||
&conn,
|
||||
&RetainedEvent {
|
||||
kind,
|
||||
pubkey: event.pubkey.to_hex(),
|
||||
d_tag: d_tag.clone(),
|
||||
content: event.content.to_string(),
|
||||
created_at: event.created_at.as_secs() as i64,
|
||||
raw_event: event.as_json(),
|
||||
pending_sync: false,
|
||||
},
|
||||
)?;
|
||||
if outcome == InboundOutcome::Skipped {
|
||||
return Ok(());
|
||||
let inbound_retained_event = RetainedEvent {
|
||||
kind,
|
||||
pubkey: event.pubkey.to_hex(),
|
||||
d_tag: d_tag.clone(),
|
||||
content: event.content.to_string(),
|
||||
created_at: event.created_at.as_secs() as i64,
|
||||
raw_event: event.as_json(),
|
||||
pending_sync: false,
|
||||
};
|
||||
// Managed-agent access changes can fail while stopping a runtime. Preflight
|
||||
// the retention decision now, but do not advance the durable head until the
|
||||
// local store has been saved; otherwise replay sees the failed revocation as
|
||||
// already consumed and can never retry it. Persona/team paths retain first
|
||||
// as before because they have no fallible runtime transition.
|
||||
if kind == KIND_MANAGED_AGENT
|
||||
&& inbound_event_outcome(&conn, &inbound_retained_event)? == InboundOutcome::Skipped
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
if kind != KIND_MANAGED_AGENT
|
||||
&& retain_inbound_event(&conn, &inbound_retained_event)? == InboundOutcome::Skipped
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut runtime_refresh = None;
|
||||
match kind {
|
||||
KIND_PERSONA => {
|
||||
let mut personas = load_personas(&app)?;
|
||||
@@ -176,8 +263,65 @@ fn reconcile_inbound_persona_event_blocking(
|
||||
let managed_agent = inbound_managed_agent.ok_or_else(|| {
|
||||
"managed-agent content was not parsed before retention".to_string()
|
||||
})?;
|
||||
apply_inbound_managed_agent(&mut agents, &d_tag, managed_agent);
|
||||
let access_changed = apply_inbound_managed_agent(&mut agents, &d_tag, managed_agent);
|
||||
if access_changed {
|
||||
let record = agents
|
||||
.iter_mut()
|
||||
.find(|record| record.pubkey == d_tag)
|
||||
.ok_or_else(|| format!("agent {d_tag} disappeared during inbound apply"))?;
|
||||
match &record.backend {
|
||||
crate::managed_agents::BackendKind::Local => {
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut relay_urls =
|
||||
crate::managed_agents::managed_agent_runtime_keys(&runtimes, &d_tag)
|
||||
.into_iter()
|
||||
.map(|key| key.relay_url)
|
||||
.collect::<Vec<_>>();
|
||||
if relay_urls.is_empty() && record.runtime_pid.is_some() {
|
||||
relay_urls.push(crate::relay::effective_agent_relay_url(
|
||||
&record.relay_url,
|
||||
&crate::relay::relay_ws_url_with_override(&state),
|
||||
));
|
||||
}
|
||||
if !relay_urls.is_empty() {
|
||||
crate::managed_agents::stop_managed_agent_process(
|
||||
&app,
|
||||
record,
|
||||
&mut runtimes,
|
||||
)?;
|
||||
runtime_refresh = Some(InboundRuntimeRefresh::Local {
|
||||
pubkey: d_tag.clone(),
|
||||
relay_urls,
|
||||
});
|
||||
}
|
||||
}
|
||||
crate::managed_agents::BackendKind::Provider { id, config }
|
||||
if record.backend_agent_id.is_some() =>
|
||||
{
|
||||
// Persist the unacknowledged policy transition in the
|
||||
// same write as the narrowed policy. If the process
|
||||
// exits before or during deployment, workspace apply
|
||||
// can still recover it in every build.
|
||||
record.provider_policy_pending = true;
|
||||
runtime_refresh = Some(InboundRuntimeRefresh::Provider {
|
||||
pubkey: d_tag.clone(),
|
||||
provider_id: id.clone(),
|
||||
config: config.clone(),
|
||||
cached_binary_path: record.provider_binary_path.clone(),
|
||||
agent_json: super::super::agents::build_deploy_payload(
|
||||
&app, &state, record,
|
||||
),
|
||||
});
|
||||
}
|
||||
crate::managed_agents::BackendKind::Provider { .. } => {}
|
||||
}
|
||||
}
|
||||
save_managed_agents(&app, &agents)?;
|
||||
let outcome = retain_inbound_event(&conn, &inbound_retained_event)?;
|
||||
debug_assert_eq!(outcome, InboundOutcome::Applied);
|
||||
}
|
||||
_ => unreachable!("kind gated above"),
|
||||
}
|
||||
@@ -187,7 +331,7 @@ fn reconcile_inbound_persona_event_blocking(
|
||||
// land on disk silently, leaving the Agents tab stale until restart.
|
||||
let _ = app.emit("agents-data-changed", ());
|
||||
|
||||
Ok(())
|
||||
Ok(runtime_refresh)
|
||||
}
|
||||
|
||||
fn validate_inbound_persona_definition(persona: &AgentDefinition) -> Result<(), String> {
|
||||
@@ -409,8 +553,10 @@ fn apply_inbound_managed_agent(
|
||||
agents: &mut [ManagedAgentRecord],
|
||||
d_tag: &str,
|
||||
inbound: ManagedAgentEventContent,
|
||||
) {
|
||||
) -> bool {
|
||||
if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) {
|
||||
let previous_mode = local.respond_to;
|
||||
let previous_allowlist = local.respond_to_allowlist.clone();
|
||||
local.name = inbound.name;
|
||||
// Mirror of the slimmed writer (agent_event_content): a
|
||||
// definition-linked event omits the definition quad because those
|
||||
@@ -428,7 +574,14 @@ fn apply_inbound_managed_agent(
|
||||
local.parallelism = inbound.parallelism;
|
||||
local.respond_to = inbound.respond_to;
|
||||
local.respond_to_allowlist = inbound.respond_to_allowlist;
|
||||
return super::super::agent_models::managed_agent_access_policy_changed(
|
||||
previous_mode,
|
||||
&previous_allowlist,
|
||||
local.respond_to,
|
||||
&local.respond_to_allowlist,
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Merge an inbound kind:30176 team projection into the local set.
|
||||
|
||||
@@ -188,6 +188,7 @@ fn local_agent() -> ManagedAgentRecord {
|
||||
config: serde_json::json!({ "api_key": "localproviderkey" }),
|
||||
},
|
||||
backend_agent_id: Some("local-remote-id".to_string()),
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: Some("/local/bin".to_string()),
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
@@ -262,8 +263,9 @@ fn inbound_managed_agent_drops_injected_secrets_and_harness() {
|
||||
let content =
|
||||
crate::managed_agents::agent_events::managed_agent_content_from_event(&event).unwrap();
|
||||
let mut agents = vec![local_agent()];
|
||||
apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content);
|
||||
let access_changed = apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content);
|
||||
|
||||
assert!(access_changed, "Anyone must trigger a runtime refresh");
|
||||
let a = &agents[0];
|
||||
// Secrets / harness / runtime — every one preserved from the local record.
|
||||
assert_eq!(
|
||||
|
||||
@@ -39,6 +39,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -626,6 +626,7 @@ pub async fn confirm_agent_snapshot_import(
|
||||
runtime_pid: None,
|
||||
backend: crate::managed_agents::BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -48,6 +48,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -31,6 +31,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge
|
||||
runtime_pid: None,
|
||||
backend: Default::default(),
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -579,6 +579,7 @@ pub async fn confirm_team_snapshot_import(
|
||||
runtime_pid: None,
|
||||
backend: crate::managed_agents::BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: Some(imported_team.id.clone()),
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -206,6 +206,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() {
|
||||
runtime_pid: None,
|
||||
backend: crate::managed_agents::BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: Some("t1".to_string()),
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -47,6 +47,8 @@ mod util;
|
||||
pub mod webkit_rendering;
|
||||
use app_state::{build_app_state, resolve_persisted_identity, AppState};
|
||||
use builderlab::*;
|
||||
#[doc(hidden)]
|
||||
pub use commands::print_agent_access_owner_only_probe_if_requested;
|
||||
use commands::*;
|
||||
use deep_link::{
|
||||
acknowledge_pending_community_deep_link, acknowledge_pending_entity_deep_link,
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
if buzz_lib::print_agent_access_owner_only_probe_if_requested() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Before anything else: WebKitGTK reads its rendering environment once at
|
||||
// process start, and this is the only point where the process is still
|
||||
// single threaded and no GTK object exists yet, which is what makes
|
||||
|
||||
@@ -193,6 +193,7 @@ mod tests {
|
||||
config: serde_json::json!({ "api_key": "sk-provider-secret" }),
|
||||
},
|
||||
backend_agent_id: Some("remote-id".to_string()),
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: Some("/path/to/binary".to_string()),
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -389,6 +389,7 @@ mod tests {
|
||||
runtime_pid: None,
|
||||
backend: crate::managed_agents::types::BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -47,6 +47,7 @@ fn minimal_record() -> ManagedAgentRecord {
|
||||
config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}),
|
||||
},
|
||||
backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear
|
||||
persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear
|
||||
persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear
|
||||
|
||||
@@ -88,6 +88,7 @@ fn test_record() -> ManagedAgentRecord {
|
||||
runtime_pid: None,
|
||||
backend: crate::managed_agents::types::BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -255,6 +255,7 @@ fn record_with(
|
||||
runtime_pid: None,
|
||||
backend: Default::default(),
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
@@ -316,8 +317,6 @@ fn record_agent_command_bare_record_defaults() {
|
||||
assert_eq!(record_agent_command(&record, &[]), default_agent_command());
|
||||
}
|
||||
|
||||
// ── try_record_agent_command ─────────────────────────────────────────────────
|
||||
|
||||
/// When the record carries a dangling (unknown) runtime id, `try_record_agent_command`
|
||||
/// must return `Err` containing "DANGLING_HARNESS_ID" — NEVER the buzz-agent default.
|
||||
/// This test would fail if the function silently fell back to `default_agent_command()`.
|
||||
|
||||
@@ -64,6 +64,7 @@ fn record(
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -324,6 +324,7 @@ fn bare_record() -> ManagedAgentRecord {
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -474,6 +474,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::default(),
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -89,6 +89,7 @@ mod tests {
|
||||
runtime_pid: None,
|
||||
backend: Default::default(),
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -247,7 +247,22 @@ pub async fn flush_active_pending_events(
|
||||
flush_pending_events_at(&scope.db_path, state, &scope.relay_url, &scope.owner_keys).await
|
||||
}
|
||||
|
||||
async fn flush_pending_events_at(
|
||||
pub fn active_pending_event(
|
||||
app: &tauri::AppHandle,
|
||||
state: &AppState,
|
||||
kind: u32,
|
||||
d_tag: &str,
|
||||
) -> Result<bool, String> {
|
||||
let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
|
||||
let owner_pubkey = scope.owner_keys.public_key().to_hex();
|
||||
let conn = crate::managed_agents::retention::open_retention_db(&scope.db_path)?;
|
||||
Ok(
|
||||
crate::managed_agents::retention::get_retained_event(&conn, kind, &owner_pubkey, d_tag)?
|
||||
.is_some_and(|event| event.pending_sync),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn flush_pending_events_at(
|
||||
db_path: &std::path::Path,
|
||||
state: &AppState,
|
||||
relay_url: &str,
|
||||
|
||||
@@ -31,6 +31,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord {
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -1503,6 +1503,7 @@ mod tests {
|
||||
runtime_pid: None,
|
||||
backend: Default::default(),
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
@@ -1546,8 +1547,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── provider-specific model fallback tests ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn buzz_agent_databricks_v2_with_databricks_model_but_no_buzz_agent_model_is_ready() {
|
||||
// The baked buzz-releases env sets DATABRICKS_MODEL but not BUZZ_AGENT_MODEL.
|
||||
|
||||
@@ -261,21 +261,32 @@ pub enum InboundOutcome {
|
||||
/// pending row intact so the flush republishes and the relay resolves
|
||||
/// last-writer-wins. (A re-received echo at equal time is also a no-op.)
|
||||
/// - Inbound older: skip — nothing to change.
|
||||
pub fn retain_inbound_event(
|
||||
///
|
||||
/// Decide whether an inbound event is newer than the retained coordinate without
|
||||
/// mutating retention. Callers that must update another durable store first use
|
||||
/// this preflight, apply that store change, and only then commit with
|
||||
/// [`retain_inbound_event`].
|
||||
pub fn inbound_event_outcome(
|
||||
conn: &Connection,
|
||||
event: &RetainedEvent,
|
||||
) -> Result<InboundOutcome, String> {
|
||||
let existing = get_retained_event(conn, event.kind, &event.pubkey, &event.d_tag)?;
|
||||
|
||||
let apply = match &existing {
|
||||
None => true,
|
||||
Some(row) if event.created_at > row.created_at => true,
|
||||
Ok(match existing {
|
||||
None => InboundOutcome::Applied,
|
||||
Some(row) if event.created_at > row.created_at => InboundOutcome::Applied,
|
||||
// Equal or older: skip. Equal time may collide with a pending local
|
||||
// edit, so we never clear its `pending_sync`; older is stale.
|
||||
Some(_) => false,
|
||||
};
|
||||
Some(_) => InboundOutcome::Skipped,
|
||||
})
|
||||
}
|
||||
|
||||
if !apply {
|
||||
pub fn retain_inbound_event(
|
||||
conn: &Connection,
|
||||
event: &RetainedEvent,
|
||||
) -> Result<InboundOutcome, String> {
|
||||
let outcome = inbound_event_outcome(conn, event)?;
|
||||
|
||||
if outcome == InboundOutcome::Skipped {
|
||||
return Ok(InboundOutcome::Skipped);
|
||||
}
|
||||
|
||||
@@ -553,6 +564,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inbound_preflight_does_not_consume_event_before_commit() {
|
||||
let conn = test_db();
|
||||
let mut inbound = sample_event();
|
||||
inbound.pending_sync = false;
|
||||
|
||||
assert_eq!(
|
||||
inbound_event_outcome(&conn, &inbound).unwrap(),
|
||||
InboundOutcome::Applied
|
||||
);
|
||||
assert!(
|
||||
get_retained_event(&conn, inbound.kind, &inbound.pubkey, &inbound.d_tag)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
// A failed store/runtime apply can replay the same head because the
|
||||
// preflight did not advance retention.
|
||||
assert_eq!(
|
||||
inbound_event_outcome(&conn, &inbound).unwrap(),
|
||||
InboundOutcome::Applied
|
||||
);
|
||||
assert_eq!(
|
||||
retain_inbound_event(&conn, &inbound).unwrap(),
|
||||
InboundOutcome::Applied
|
||||
);
|
||||
assert_eq!(
|
||||
inbound_event_outcome(&conn, &inbound).unwrap(),
|
||||
InboundOutcome::Skipped
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_and_retrieve() {
|
||||
let conn = test_db();
|
||||
|
||||
@@ -62,6 +62,7 @@ pub(super) fn fixture(
|
||||
runtime_pid: None,
|
||||
backend: Default::default(),
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -43,6 +43,7 @@ fn record() -> ManagedAgentRecord {
|
||||
runtime_pid: None,
|
||||
backend: Default::default(),
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -283,6 +283,7 @@ mod tests {
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
|
||||
@@ -190,6 +190,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord {
|
||||
runtime_pid: None,
|
||||
backend: crate::managed_agents::BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
persona_team_dir: None,
|
||||
persona_name_in_team: None,
|
||||
|
||||
@@ -125,6 +125,7 @@ impl AgentDefinition {
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::default(),
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
@@ -196,6 +197,8 @@ impl ManagedAgentRecord {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelayAgentInfo {
|
||||
pub pubkey: String,
|
||||
#[serde(default)]
|
||||
pub owner_pubkey: Option<String>,
|
||||
pub name: String,
|
||||
pub agent_type: String,
|
||||
pub channels: Vec<String>,
|
||||
@@ -245,13 +248,9 @@ pub struct ManagedAgentRecord {
|
||||
pub avatar_url: Option<String>,
|
||||
pub acp_command: String,
|
||||
pub agent_command: String,
|
||||
/// Explicit per-instance harness pin. `None` (the default) means inherit
|
||||
/// the harness from the linked persona's `runtime`, so persona harness
|
||||
/// edits propagate on the next spawn — mirroring the opt-in `model`
|
||||
/// override. `Some` is set only when the user deliberately picks a harness
|
||||
/// that diverges from the persona. Resolved via `effective_agent_command`;
|
||||
/// `agent_command` above is the create-time snapshot kept for avatar/legacy
|
||||
/// derivations and is not authoritative for spawn.
|
||||
/// Explicit per-instance harness pin; `None` inherits the persona runtime.
|
||||
/// The effective command is resolved at spawn; `agent_command` is a legacy
|
||||
/// create-time snapshot.
|
||||
#[serde(default)]
|
||||
pub agent_command_override: Option<String>,
|
||||
pub agent_args: Vec<String>,
|
||||
@@ -321,6 +320,8 @@ pub struct ManagedAgentRecord {
|
||||
#[serde(default)]
|
||||
pub backend_agent_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub provider_policy_pending: bool,
|
||||
#[serde(default)]
|
||||
pub provider_binary_path: Option<String>,
|
||||
/// Installed team directory path (absolute). Set when agent was created from a team persona.
|
||||
#[serde(
|
||||
|
||||
@@ -442,6 +442,21 @@ fn managed_agent_record_without_key_deserializes_empty() {
|
||||
.expect("keyring-backed record without inline key should deserialize");
|
||||
|
||||
assert_eq!(record.private_key_nsec, "");
|
||||
assert!(
|
||||
!record.provider_policy_pending,
|
||||
"pre-pending stores must deserialize as acknowledged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_provider_policy_round_trips() {
|
||||
let mut record = sample_agent_record();
|
||||
record.provider_policy_pending = true;
|
||||
|
||||
let json = serde_json::to_string(&record).expect("serialize pending policy");
|
||||
let reloaded: ManagedAgentRecord = serde_json::from_str(&json).expect("reload pending policy");
|
||||
|
||||
assert!(reloaded.provider_policy_pending);
|
||||
}
|
||||
|
||||
fn sample_agent_record() -> ManagedAgentRecord {
|
||||
|
||||
@@ -495,6 +495,15 @@ pub fn agents_from_events(events: &[Event]) -> Value {
|
||||
json!({ "agents": arr })
|
||||
}
|
||||
|
||||
// ── kind:0 + kind:30177 managed-agent directory ────────────────────────────
|
||||
|
||||
mod agent_directory;
|
||||
pub use agent_directory::{
|
||||
managed_agent_pubkeys_from_events, member_agent_channel_ids_from_events,
|
||||
relay_agents_from_directory_events, relay_agents_from_managed_agent_events,
|
||||
verified_agent_owners_from_profiles,
|
||||
};
|
||||
|
||||
// ── kind:13534 (relay membership list) ──────────────────────────────────────
|
||||
|
||||
/// Convert a kind:13534 relay membership list to the relay members format.
|
||||
@@ -578,434 +587,4 @@ fn days_to_ymd(days: i64) -> (i64, u32, u32) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
/// Build a signed event for testing with the given kind, content, and tags.
|
||||
fn ev(kind: u16, content: &str, tags: Vec<Vec<&str>>) -> Event {
|
||||
let keys = Keys::generate();
|
||||
let parsed: Vec<Tag> = tags
|
||||
.into_iter()
|
||||
.map(|t| Tag::parse(t).expect("parse tag"))
|
||||
.collect();
|
||||
EventBuilder::new(Kind::from_u16(kind), content)
|
||||
.tags(parsed)
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign")
|
||||
}
|
||||
|
||||
/// Build a kind:0 profile with a valid NIP-OA auth tag.
|
||||
fn oa_profile_event(content: &str) -> (Event, String) {
|
||||
let agent_keys = Keys::generate();
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_pubkey = agent_keys.public_key();
|
||||
let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, "")
|
||||
.expect("compute auth tag");
|
||||
let tag_values: Vec<String> = serde_json::from_str(&tag_json).expect("parse auth tag json");
|
||||
let auth_tag = Tag::parse(tag_values).expect("parse auth tag");
|
||||
|
||||
let event = EventBuilder::new(Kind::Metadata, content)
|
||||
.tags(vec![auth_tag])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.expect("sign");
|
||||
(event, owner_keys.public_key().to_hex())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_minimal() {
|
||||
let e = ev(
|
||||
39000,
|
||||
"",
|
||||
vec![
|
||||
vec!["d", "chan-uuid-1"],
|
||||
vec!["name", "general"],
|
||||
vec!["about", "main channel"],
|
||||
vec!["t", "stream"],
|
||||
vec!["public"],
|
||||
],
|
||||
);
|
||||
let info = channel_info_from_event(&e, None, None).unwrap();
|
||||
assert_eq!(info.id, "chan-uuid-1");
|
||||
assert_eq!(info.name, "general");
|
||||
assert_eq!(info.description, "main channel");
|
||||
assert_eq!(info.channel_type, "stream");
|
||||
assert_eq!(info.visibility, "open");
|
||||
assert_eq!(info.member_count, 0);
|
||||
assert!(info.is_member);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_private_when_visibility_tag_present() {
|
||||
let e = ev(
|
||||
39000,
|
||||
"",
|
||||
vec![
|
||||
vec!["d", "u"],
|
||||
vec!["name", "n"],
|
||||
vec!["t", "forum"],
|
||||
vec!["visibility", "private"],
|
||||
vec!["ttl", "86400"],
|
||||
],
|
||||
);
|
||||
let info = channel_info_from_event(&e, None, None).unwrap();
|
||||
assert_eq!(info.visibility, "private");
|
||||
assert_eq!(info.channel_type, "forum");
|
||||
assert_eq!(info.ttl_seconds, Some(86400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_open_when_neither_public_nor_private() {
|
||||
// Neither tag present → open (matches NIP-29 default).
|
||||
let e = ev(
|
||||
39000,
|
||||
"",
|
||||
vec![vec!["d", "u"], vec!["name", "n"], vec!["t", "forum"]],
|
||||
);
|
||||
let info = channel_info_from_event(&e, None, None).unwrap();
|
||||
assert_eq!(info.visibility, "open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_dm_inferred_from_hidden_tag() {
|
||||
// Fallback: relays without ["t", "dm"] still emit ["hidden"] for DMs.
|
||||
let e = ev(
|
||||
39000,
|
||||
"",
|
||||
vec![vec!["d", "u"], vec!["name", "n"], vec!["hidden"]],
|
||||
);
|
||||
let info = channel_info_from_event(&e, None, None).unwrap();
|
||||
assert_eq!(info.channel_type, "dm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_merges_summary() {
|
||||
let chan = ev(39000, "", vec![vec!["d", "u"], vec!["name", "n"]]);
|
||||
let summary = ev(
|
||||
40901,
|
||||
r#"{"member_count": 7, "last_message_at": "2026-01-01T00:00:00Z"}"#,
|
||||
vec![vec!["d", "u"]],
|
||||
);
|
||||
let info = channel_info_from_event(&chan, Some(&summary), None).unwrap();
|
||||
assert_eq!(info.member_count, 7);
|
||||
assert_eq!(
|
||||
info.last_message_at.as_deref(),
|
||||
Some("2026-01-01T00:00:00Z")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_missing_d_errors() {
|
||||
let e = ev(39000, "", vec![vec!["name", "n"]]);
|
||||
assert!(channel_info_from_event(&e, None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_detail_basic() {
|
||||
let e = ev(
|
||||
39000,
|
||||
"",
|
||||
vec![
|
||||
vec!["d", "uuid"],
|
||||
vec!["name", "n"],
|
||||
vec!["about", "desc"],
|
||||
vec!["topic", "tt"],
|
||||
vec!["purpose", "pp"],
|
||||
vec!["t", "dm"],
|
||||
vec!["visibility", "private"],
|
||||
vec!["ttl", "86400"],
|
||||
vec!["ttl_deadline", "2026-06-11T00:00:00Z"],
|
||||
],
|
||||
);
|
||||
let d = channel_detail_from_event(&e).unwrap();
|
||||
assert_eq!(d.id, "uuid");
|
||||
assert_eq!(d.topic.as_deref(), Some("tt"));
|
||||
assert_eq!(d.purpose.as_deref(), Some("pp"));
|
||||
assert_eq!(d.channel_type, "dm");
|
||||
assert_eq!(d.visibility, "private");
|
||||
assert_eq!(d.ttl_seconds, Some(86400));
|
||||
assert_eq!(d.ttl_deadline.as_deref(), Some("2026-06-11T00:00:00Z"));
|
||||
assert!(d.created_at.ends_with("Z"));
|
||||
assert_eq!(d.created_by, e.pubkey.to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_members_extracts_p_tags() {
|
||||
let pk1 = "a".repeat(64);
|
||||
let pk2 = "b".repeat(64);
|
||||
let e = ev(
|
||||
39002,
|
||||
"",
|
||||
vec![
|
||||
vec!["d", "uuid"],
|
||||
vec!["p", &pk1, "", "admin"],
|
||||
vec!["p", &pk2],
|
||||
// Duplicate must be deduped.
|
||||
vec!["p", &pk1, "wss://x", "owner"],
|
||||
],
|
||||
);
|
||||
let r = channel_members_from_event(&e).unwrap();
|
||||
assert_eq!(r.members.len(), 2);
|
||||
assert_eq!(r.members[0].pubkey, pk1);
|
||||
assert_eq!(r.members[0].role, "admin");
|
||||
assert!(r.members[0].joined_at.is_none());
|
||||
assert_eq!(r.members[1].role, "member"); // default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_members_missing_d_errors() {
|
||||
let e = ev(39002, "", vec![]);
|
||||
assert!(channel_members_from_event(&e).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_info_parses_content() {
|
||||
let e = ev(
|
||||
0,
|
||||
r#"{"name":"alice","display_name":"Alice","picture":"http://x/a.png","about":"hi","nip05":"alice@x"}"#,
|
||||
vec![],
|
||||
);
|
||||
let p = profile_info_from_event(&e).unwrap();
|
||||
assert_eq!(p.display_name.as_deref(), Some("Alice"));
|
||||
assert_eq!(p.avatar_url.as_deref(), Some("http://x/a.png"));
|
||||
assert_eq!(p.about.as_deref(), Some("hi"));
|
||||
assert_eq!(p.nip05_handle.as_deref(), Some("alice@x"));
|
||||
assert_eq!(p.pubkey, e.pubkey.to_hex());
|
||||
assert!(p.owner_pubkey.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_info_extracts_valid_nip_oa_owner() {
|
||||
let (event, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#);
|
||||
let p = profile_info_from_event(&event).unwrap();
|
||||
|
||||
assert_eq!(p.owner_pubkey.as_deref(), Some(owner_pubkey.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_info_falls_back_to_name() {
|
||||
let e = ev(0, r#"{"name":"bob"}"#, vec![]);
|
||||
let p = profile_info_from_event(&e).unwrap();
|
||||
assert_eq!(p.display_name.as_deref(), Some("bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_info_invalid_json_errors() {
|
||||
let e = ev(0, "not-json", vec![]);
|
||||
assert!(profile_info_from_event(&e).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn users_batch_keeps_latest_and_reports_missing() {
|
||||
let e1 = ev(0, r#"{"name":"old"}"#, vec![]);
|
||||
// Same author, newer event with display_name.
|
||||
let keys = Keys::generate();
|
||||
let e_old = EventBuilder::new(Kind::Metadata, r#"{"name":"old"}"#)
|
||||
.custom_created_at(nostr::Timestamp::from(1000))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let e_new = EventBuilder::new(Kind::Metadata, r#"{"display_name":"New"}"#)
|
||||
.custom_created_at(nostr::Timestamp::from(2000))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let pk = keys.public_key().to_hex();
|
||||
let other_pk = e1.pubkey.to_hex();
|
||||
|
||||
let missing_pk = "f".repeat(64);
|
||||
let resp = users_batch_from_events(
|
||||
&[e1, e_old, e_new],
|
||||
&[pk.clone(), other_pk.clone(), missing_pk.clone()],
|
||||
);
|
||||
assert_eq!(resp.profiles.len(), 2);
|
||||
assert_eq!(resp.profiles[&pk].display_name.as_deref(), Some("New"));
|
||||
assert_eq!(resp.missing, vec![missing_pk]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn users_batch_marks_valid_nip_oa_profiles_as_agents() {
|
||||
let (agent, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#);
|
||||
let pubkey = agent.pubkey.to_hex();
|
||||
let resp =
|
||||
users_batch_from_events(std::slice::from_ref(&agent), std::slice::from_ref(&pubkey));
|
||||
|
||||
assert!(resp.profiles[&pubkey].is_agent);
|
||||
assert_eq!(
|
||||
resp.profiles[&pubkey].owner_pubkey.as_deref(),
|
||||
Some(owner_pubkey.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_notes_builds_cursor_from_last() {
|
||||
let e1 = ev(1, "first", vec![]);
|
||||
let e2 = ev(1, "second", vec![]);
|
||||
let r = user_notes_from_events(&[e1, e2]);
|
||||
assert_eq!(r.notes.len(), 2);
|
||||
assert_eq!(r.notes[0].content, "first");
|
||||
let cursor = r.next_cursor.expect("cursor");
|
||||
assert_eq!(cursor.before_id, r.notes[1].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_notes_empty_has_no_cursor() {
|
||||
let r = user_notes_from_events(&[]);
|
||||
assert!(r.notes.is_empty());
|
||||
assert!(r.next_cursor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_list_preserves_tags_and_content() {
|
||||
let pk = "1".repeat(64);
|
||||
let e = ev(3, "rel-json", vec![vec!["p", &pk]]);
|
||||
let r = contact_list_from_event(&e).unwrap();
|
||||
assert_eq!(r.content, "rel-json");
|
||||
assert_eq!(r.tags.len(), 1);
|
||||
assert_eq!(r.tags[0], vec!["p".to_string(), pk]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_response_assigns_descending_scores() {
|
||||
let e1 = ev(1, "one", vec![vec!["h", "chan"]]);
|
||||
let e2 = ev(1, "two", vec![]);
|
||||
let r = search_response_from_events(&[e1, e2]);
|
||||
assert_eq!(r.found, 2);
|
||||
assert!(r.hits[0].score > r.hits[1].score);
|
||||
assert_eq!(r.hits[0].channel_id.as_deref(), Some("chan"));
|
||||
assert!(r.hits[1].channel_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_response_single_hit_full_score() {
|
||||
let e = ev(1, "only", vec![]);
|
||||
let r = search_response_from_events(&[e]);
|
||||
assert_eq!(r.hits.len(), 1);
|
||||
assert_eq!(r.hits[0].score, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_overwrites_pubkey_from_event_author() {
|
||||
let e = ev(10100, r#"{"pubkey":"forged","name":"agent-1"}"#, vec![]);
|
||||
let v = agents_from_events(std::slice::from_ref(&e));
|
||||
let arr = v.get("agents").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(
|
||||
arr[0].get("pubkey").and_then(Value::as_str).unwrap(),
|
||||
e.pubkey.to_hex()
|
||||
);
|
||||
assert_eq!(arr[0].get("name").and_then(Value::as_str), Some("agent-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_handles_invalid_content() {
|
||||
let e = ev(10100, "not-json", vec![]);
|
||||
let v = agents_from_events(std::slice::from_ref(&e));
|
||||
let arr = v.get("agents").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(
|
||||
arr[0].get("pubkey").and_then(Value::as_str).unwrap(),
|
||||
e.pubkey.to_hex()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_default_sparse_agent_profiles_for_directory_parse() {
|
||||
let e = ev(
|
||||
10100,
|
||||
r#"{"channel_add_policy":"owner-only","display_name":"Scout"}"#,
|
||||
vec![],
|
||||
);
|
||||
let v = agents_from_events(std::slice::from_ref(&e));
|
||||
let agents = v.get("agents").cloned().unwrap();
|
||||
let parsed: Vec<crate::managed_agents::RelayAgentInfo> =
|
||||
serde_json::from_value(agents).unwrap();
|
||||
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].pubkey, e.pubkey.to_hex());
|
||||
assert_eq!(parsed[0].name, "Scout");
|
||||
assert_eq!(parsed[0].agent_type, "agent");
|
||||
assert_eq!(parsed[0].channels, Vec::<String>::new());
|
||||
assert_eq!(parsed[0].capabilities, Vec::<String>::new());
|
||||
assert_eq!(parsed[0].status, "offline");
|
||||
assert_eq!(parsed[0].respond_to, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_preserves_public_respond_to_mode_for_directory_parse() {
|
||||
let e = ev(10100, r#"{"name":"Scout","respond_to":"anyone"}"#, vec![]);
|
||||
let v = agents_from_events(std::slice::from_ref(&e));
|
||||
let agents = v.get("agents").cloned().unwrap();
|
||||
let parsed: Vec<crate::managed_agents::RelayAgentInfo> =
|
||||
serde_json::from_value(agents).unwrap();
|
||||
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(
|
||||
parsed[0].respond_to,
|
||||
Some(crate::managed_agents::RespondTo::Anyone)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_preserves_allowlist_metadata_for_directory_parse() {
|
||||
let e = ev(
|
||||
10100,
|
||||
r#"{"name":"Scout","respond_to":"allowlist","respond_to_allowlist":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#,
|
||||
vec![],
|
||||
);
|
||||
let v = agents_from_events(std::slice::from_ref(&e));
|
||||
let agents = v.get("agents").cloned().unwrap();
|
||||
let parsed: Vec<crate::managed_agents::RelayAgentInfo> =
|
||||
serde_json::from_value(agents).unwrap();
|
||||
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(
|
||||
parsed[0].respond_to,
|
||||
Some(crate::managed_agents::RespondTo::Allowlist)
|
||||
);
|
||||
assert_eq!(parsed[0].respond_to_allowlist, vec!["a".repeat(64)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_members_dedupes_and_defaults_role() {
|
||||
let pk1 = "a".repeat(64);
|
||||
let pk2 = "b".repeat(64);
|
||||
// Current relay format: ["member", pubkey, role]
|
||||
let e = ev(
|
||||
13534,
|
||||
"",
|
||||
vec![
|
||||
vec!["member", &pk1, "owner"],
|
||||
vec!["member", &pk2],
|
||||
vec!["member", &pk1, "moderator"], // dupe — ignored
|
||||
],
|
||||
);
|
||||
let v = relay_members_from_event(&e);
|
||||
let arr = v.get("members").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("owner"));
|
||||
assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_members_fallback_p_tags() {
|
||||
let pk1 = "a".repeat(64);
|
||||
let pk2 = "b".repeat(64);
|
||||
// Legacy/fallback format: ["p", pubkey, relay_url?, role?]
|
||||
let e = ev(
|
||||
13534,
|
||||
"",
|
||||
vec![vec!["p", &pk1, "", "admin"], vec!["p", &pk2]],
|
||||
);
|
||||
let v = relay_members_from_event(&e);
|
||||
let arr = v.get("members").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("admin"));
|
||||
assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_to_iso_known_value() {
|
||||
// 2021-01-01T00:00:00Z = 1609459200
|
||||
assert_eq!(timestamp_to_iso(1_609_459_200), "2021-01-01T00:00:00Z");
|
||||
// Epoch
|
||||
assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z");
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Conversion and verification for relay-discovered agents.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use nostr::Event;
|
||||
|
||||
use crate::managed_agents::{agent_events::managed_agent_content_from_event, RelayAgentInfo};
|
||||
|
||||
use super::{agents_from_events, first_tag_value, profile_valid_oa_owner_pubkey, tags_named};
|
||||
|
||||
/// Collect valid agent pubkeys from kind:30177 `d` tags for follow-up relay
|
||||
/// queries. Malformed tags are ignored so one hostile event cannot invalidate
|
||||
/// the whole directory request.
|
||||
pub fn managed_agent_pubkeys_from_events(events: &[Event]) -> std::collections::HashSet<String> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|event| first_tag_value(event, "d"))
|
||||
.filter_map(|pubkey| nostr::PublicKey::from_hex(pubkey).ok())
|
||||
.map(|pubkey| pubkey.to_hex())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn event_is_newer(candidate: &Event, previous: &Event) -> bool {
|
||||
candidate.created_at > previous.created_at
|
||||
|| (candidate.created_at == previous.created_at && candidate.id < previous.id)
|
||||
}
|
||||
|
||||
fn relay_agents_from_legacy_events(events: &[Event]) -> Vec<RelayAgentInfo> {
|
||||
let mut latest: HashMap<String, &Event> = HashMap::new();
|
||||
for event in events {
|
||||
let pubkey = event.pubkey.to_hex();
|
||||
if latest
|
||||
.get(&pubkey)
|
||||
.is_none_or(|previous| event_is_newer(event, previous))
|
||||
{
|
||||
latest.insert(pubkey, event);
|
||||
}
|
||||
}
|
||||
|
||||
latest
|
||||
.into_values()
|
||||
.filter_map(|event| {
|
||||
let value = agents_from_events(std::slice::from_ref(event));
|
||||
let mut agent: RelayAgentInfo =
|
||||
serde_json::from_value(value.get("agents")?.as_array()?.first()?.clone()).ok()?;
|
||||
// Legacy directory entries are not authenticated managed-policy
|
||||
// coordinates, so they must not drive the live 30177 watcher.
|
||||
agent.owner_pubkey = None;
|
||||
// Channel membership is authoritative only in relay-signed kind:39002.
|
||||
agent.channel_ids.clear();
|
||||
Some(agent)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Merge self-authored kind:10100 runtime profiles with verified Desktop-managed
|
||||
/// policy records. A verified managed coordinate reserves the agent identity even
|
||||
/// when its current policy is malformed, so stale legacy permissions cannot win.
|
||||
pub fn relay_agents_from_directory_events(
|
||||
directory_events: &[Event],
|
||||
managed_agent_events: &[Event],
|
||||
profile_events: &[Event],
|
||||
) -> Vec<RelayAgentInfo> {
|
||||
let verified_policies = latest_verified_managed_policies(managed_agent_events, profile_events);
|
||||
let mut agents: HashMap<String, RelayAgentInfo> =
|
||||
relay_agents_from_legacy_events(directory_events)
|
||||
.into_iter()
|
||||
.map(|agent| (agent.pubkey.clone(), agent))
|
||||
.collect();
|
||||
for agent_pubkey in verified_policies.keys() {
|
||||
agents.remove(agent_pubkey);
|
||||
}
|
||||
for (agent_pubkey, event) in verified_policies {
|
||||
if let Some(agent) = relay_agent_from_managed_policy(&agent_pubkey, event) {
|
||||
agents.insert(agent_pubkey, agent);
|
||||
}
|
||||
}
|
||||
|
||||
let mut agents: Vec<_> = agents.into_values().collect();
|
||||
agents.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
agents
|
||||
}
|
||||
|
||||
/// Resolve each agent's owner from its latest signed NIP-OA profile.
|
||||
pub fn verified_agent_owners_from_profiles(events: &[Event]) -> HashMap<String, String> {
|
||||
let mut latest_profiles: HashMap<String, &Event> = HashMap::new();
|
||||
for profile in events {
|
||||
let agent_pubkey = profile.pubkey.to_hex();
|
||||
if latest_profiles
|
||||
.get(&agent_pubkey)
|
||||
.is_none_or(|previous| event_is_newer(profile, previous))
|
||||
{
|
||||
latest_profiles.insert(agent_pubkey, profile);
|
||||
}
|
||||
}
|
||||
latest_profiles
|
||||
.into_iter()
|
||||
.filter_map(|(agent_pubkey, profile)| {
|
||||
profile_valid_oa_owner_pubkey(profile).map(|owner| (agent_pubkey, owner))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn latest_verified_managed_policies<'a>(
|
||||
managed_agent_events: &'a [Event],
|
||||
profile_events: &[Event],
|
||||
) -> HashMap<String, &'a Event> {
|
||||
let verified_owners = verified_agent_owners_from_profiles(profile_events);
|
||||
|
||||
let mut latest: HashMap<String, &'a Event> = HashMap::new();
|
||||
for event in managed_agent_events {
|
||||
let Some(agent_pubkey) = first_tag_value(event, "d") else {
|
||||
continue;
|
||||
};
|
||||
if verified_owners.get(agent_pubkey) != Some(&event.pubkey.to_hex()) {
|
||||
continue;
|
||||
}
|
||||
if latest
|
||||
.get(agent_pubkey)
|
||||
.is_none_or(|previous| event_is_newer(event, previous))
|
||||
{
|
||||
latest.insert(agent_pubkey.to_string(), event);
|
||||
}
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option<RelayAgentInfo> {
|
||||
let content = managed_agent_content_from_event(event).ok()?;
|
||||
Some(RelayAgentInfo {
|
||||
pubkey: agent_pubkey.to_string(),
|
||||
owner_pubkey: Some(event.pubkey.to_hex()),
|
||||
name: content.name,
|
||||
agent_type: "agent".to_string(),
|
||||
channels: Vec::new(),
|
||||
channel_ids: Vec::new(),
|
||||
capabilities: Vec::new(),
|
||||
status: "offline".to_string(),
|
||||
respond_to: Some(content.respond_to),
|
||||
respond_to_allowlist: content.respond_to_allowlist,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the relay agent directory from owner-authenticated managed-agent
|
||||
/// records. A kind:30177 event is accepted only when its author matches the
|
||||
/// owner cryptographically declared by the agent's latest kind:0 NIP-OA tag.
|
||||
pub fn relay_agents_from_managed_agent_events(
|
||||
managed_agent_events: &[Event],
|
||||
profile_events: &[Event],
|
||||
) -> Vec<RelayAgentInfo> {
|
||||
let mut agents: Vec<_> = latest_verified_managed_policies(managed_agent_events, profile_events)
|
||||
.into_iter()
|
||||
.filter_map(|(agent_pubkey, event)| relay_agent_from_managed_policy(&agent_pubkey, event))
|
||||
.collect();
|
||||
agents.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
agents
|
||||
}
|
||||
|
||||
/// Build a pubkey-to-channel-id candidate map from relay-signed membership
|
||||
/// events. Only p-tags explicitly marked with the `bot` role are agents.
|
||||
pub fn member_agent_channel_ids_from_events(
|
||||
events: &[Event],
|
||||
relay_pubkey: &str,
|
||||
) -> HashMap<String, Vec<String>> {
|
||||
let mut channel_ids: HashMap<String, BTreeSet<String>> = HashMap::new();
|
||||
for event in events {
|
||||
if !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey) {
|
||||
continue;
|
||||
}
|
||||
let Some(channel_id) = first_tag_value(event, "d") else {
|
||||
continue;
|
||||
};
|
||||
for tag in tags_named(event, "p") {
|
||||
let (Some(pubkey), Some(role)) = (tag.get(1), tag.get(3)) else {
|
||||
continue;
|
||||
};
|
||||
if role != "bot" || nostr::PublicKey::from_hex(pubkey).is_err() {
|
||||
continue;
|
||||
}
|
||||
channel_ids
|
||||
.entry(pubkey.clone())
|
||||
.or_default()
|
||||
.insert(channel_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
channel_ids
|
||||
.into_iter()
|
||||
.map(|(pubkey, ids)| (pubkey, ids.into_iter().collect()))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
//! Tests for the Nostr conversion surface.
|
||||
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
/// Build a signed event for testing with the given kind, content, and tags.
|
||||
fn ev(kind: u16, content: &str, tags: Vec<Vec<&str>>) -> Event {
|
||||
let keys = Keys::generate();
|
||||
let parsed: Vec<Tag> = tags
|
||||
.into_iter()
|
||||
.map(|t| Tag::parse(t).expect("parse tag"))
|
||||
.collect();
|
||||
EventBuilder::new(Kind::from_u16(kind), content)
|
||||
.tags(parsed)
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign")
|
||||
}
|
||||
|
||||
/// Build a kind:0 profile with a valid NIP-OA auth tag.
|
||||
fn oa_profile_event(content: &str) -> (Event, String) {
|
||||
let agent_keys = Keys::generate();
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_pubkey = agent_keys.public_key();
|
||||
let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, "")
|
||||
.expect("compute auth tag");
|
||||
let tag_values: Vec<String> = serde_json::from_str(&tag_json).expect("parse auth tag json");
|
||||
let auth_tag = Tag::parse(tag_values).expect("parse auth tag");
|
||||
|
||||
let event = EventBuilder::new(Kind::Metadata, content)
|
||||
.tags(vec![auth_tag])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.expect("sign");
|
||||
(event, owner_keys.public_key().to_hex())
|
||||
}
|
||||
|
||||
fn managed_agent_event(
|
||||
owner_keys: &Keys,
|
||||
agent_pubkey: &str,
|
||||
name: &str,
|
||||
respond_to: &str,
|
||||
respond_to_allowlist: &[String],
|
||||
) -> Event {
|
||||
let content = serde_json::json!({
|
||||
"name": name,
|
||||
"parallelism": 1,
|
||||
"respond_to": respond_to,
|
||||
"respond_to_allowlist": respond_to_allowlist,
|
||||
})
|
||||
.to_string();
|
||||
EventBuilder::new(Kind::Custom(30177), content)
|
||||
.tags([Tag::parse(["d", agent_pubkey]).expect("parse d tag")])
|
||||
.sign_with_keys(owner_keys)
|
||||
.expect("sign managed-agent event")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_minimal() {
|
||||
let e = ev(
|
||||
39000,
|
||||
"",
|
||||
vec![
|
||||
vec!["d", "chan-uuid-1"],
|
||||
vec!["name", "general"],
|
||||
vec!["about", "main channel"],
|
||||
vec!["t", "stream"],
|
||||
vec!["public"],
|
||||
],
|
||||
);
|
||||
let info = channel_info_from_event(&e, None, None).unwrap();
|
||||
assert_eq!(info.id, "chan-uuid-1");
|
||||
assert_eq!(info.name, "general");
|
||||
assert_eq!(info.description, "main channel");
|
||||
assert_eq!(info.channel_type, "stream");
|
||||
assert_eq!(info.visibility, "open");
|
||||
assert_eq!(info.member_count, 0);
|
||||
assert!(info.is_member);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_private_when_visibility_tag_present() {
|
||||
let e = ev(
|
||||
39000,
|
||||
"",
|
||||
vec![
|
||||
vec!["d", "u"],
|
||||
vec!["name", "n"],
|
||||
vec!["t", "forum"],
|
||||
vec!["visibility", "private"],
|
||||
vec!["ttl", "86400"],
|
||||
],
|
||||
);
|
||||
let info = channel_info_from_event(&e, None, None).unwrap();
|
||||
assert_eq!(info.visibility, "private");
|
||||
assert_eq!(info.channel_type, "forum");
|
||||
assert_eq!(info.ttl_seconds, Some(86400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_open_when_neither_public_nor_private() {
|
||||
// Neither tag present → open (matches NIP-29 default).
|
||||
let e = ev(
|
||||
39000,
|
||||
"",
|
||||
vec![vec!["d", "u"], vec!["name", "n"], vec!["t", "forum"]],
|
||||
);
|
||||
let info = channel_info_from_event(&e, None, None).unwrap();
|
||||
assert_eq!(info.visibility, "open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_dm_inferred_from_hidden_tag() {
|
||||
// Fallback: relays without ["t", "dm"] still emit ["hidden"] for DMs.
|
||||
let e = ev(
|
||||
39000,
|
||||
"",
|
||||
vec![vec!["d", "u"], vec!["name", "n"], vec!["hidden"]],
|
||||
);
|
||||
let info = channel_info_from_event(&e, None, None).unwrap();
|
||||
assert_eq!(info.channel_type, "dm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_merges_summary() {
|
||||
let chan = ev(39000, "", vec![vec!["d", "u"], vec!["name", "n"]]);
|
||||
let summary = ev(
|
||||
40901,
|
||||
r#"{"member_count": 7, "last_message_at": "2026-01-01T00:00:00Z"}"#,
|
||||
vec![vec!["d", "u"]],
|
||||
);
|
||||
let info = channel_info_from_event(&chan, Some(&summary), None).unwrap();
|
||||
assert_eq!(info.member_count, 7);
|
||||
assert_eq!(
|
||||
info.last_message_at.as_deref(),
|
||||
Some("2026-01-01T00:00:00Z")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_missing_d_errors() {
|
||||
let e = ev(39000, "", vec![vec!["name", "n"]]);
|
||||
assert!(channel_info_from_event(&e, None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_detail_basic() {
|
||||
let e = ev(
|
||||
39000,
|
||||
"",
|
||||
vec![
|
||||
vec!["d", "uuid"],
|
||||
vec!["name", "n"],
|
||||
vec!["about", "desc"],
|
||||
vec!["topic", "tt"],
|
||||
vec!["purpose", "pp"],
|
||||
vec!["t", "dm"],
|
||||
vec!["visibility", "private"],
|
||||
vec!["ttl", "86400"],
|
||||
vec!["ttl_deadline", "2026-06-11T00:00:00Z"],
|
||||
],
|
||||
);
|
||||
let d = channel_detail_from_event(&e).unwrap();
|
||||
assert_eq!(d.id, "uuid");
|
||||
assert_eq!(d.topic.as_deref(), Some("tt"));
|
||||
assert_eq!(d.purpose.as_deref(), Some("pp"));
|
||||
assert_eq!(d.channel_type, "dm");
|
||||
assert_eq!(d.visibility, "private");
|
||||
assert_eq!(d.ttl_seconds, Some(86400));
|
||||
assert_eq!(d.ttl_deadline.as_deref(), Some("2026-06-11T00:00:00Z"));
|
||||
assert!(d.created_at.ends_with("Z"));
|
||||
assert_eq!(d.created_by, e.pubkey.to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_members_extracts_p_tags() {
|
||||
let pk1 = "a".repeat(64);
|
||||
let pk2 = "b".repeat(64);
|
||||
let e = ev(
|
||||
39002,
|
||||
"",
|
||||
vec![
|
||||
vec!["d", "uuid"],
|
||||
vec!["p", &pk1, "", "admin"],
|
||||
vec!["p", &pk2],
|
||||
// Duplicate must be deduped.
|
||||
vec!["p", &pk1, "wss://x", "owner"],
|
||||
],
|
||||
);
|
||||
let r = channel_members_from_event(&e).unwrap();
|
||||
assert_eq!(r.members.len(), 2);
|
||||
assert_eq!(r.members[0].pubkey, pk1);
|
||||
assert_eq!(r.members[0].role, "admin");
|
||||
assert!(r.members[0].joined_at.is_none());
|
||||
assert_eq!(r.members[1].role, "member"); // default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_members_missing_d_errors() {
|
||||
let e = ev(39002, "", vec![]);
|
||||
assert!(channel_members_from_event(&e).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_info_parses_content() {
|
||||
let e = ev(
|
||||
0,
|
||||
r#"{"name":"alice","display_name":"Alice","picture":"http://x/a.png","about":"hi","nip05":"alice@x"}"#,
|
||||
vec![],
|
||||
);
|
||||
let p = profile_info_from_event(&e).unwrap();
|
||||
assert_eq!(p.display_name.as_deref(), Some("Alice"));
|
||||
assert_eq!(p.avatar_url.as_deref(), Some("http://x/a.png"));
|
||||
assert_eq!(p.about.as_deref(), Some("hi"));
|
||||
assert_eq!(p.nip05_handle.as_deref(), Some("alice@x"));
|
||||
assert_eq!(p.pubkey, e.pubkey.to_hex());
|
||||
assert!(p.owner_pubkey.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_info_extracts_valid_nip_oa_owner() {
|
||||
let (event, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#);
|
||||
let p = profile_info_from_event(&event).unwrap();
|
||||
|
||||
assert_eq!(p.owner_pubkey.as_deref(), Some(owner_pubkey.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_info_falls_back_to_name() {
|
||||
let e = ev(0, r#"{"name":"bob"}"#, vec![]);
|
||||
let p = profile_info_from_event(&e).unwrap();
|
||||
assert_eq!(p.display_name.as_deref(), Some("bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_info_invalid_json_errors() {
|
||||
let e = ev(0, "not-json", vec![]);
|
||||
assert!(profile_info_from_event(&e).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn users_batch_keeps_latest_and_reports_missing() {
|
||||
let e1 = ev(0, r#"{"name":"old"}"#, vec![]);
|
||||
// Same author, newer event with display_name.
|
||||
let keys = Keys::generate();
|
||||
let e_old = EventBuilder::new(Kind::Metadata, r#"{"name":"old"}"#)
|
||||
.custom_created_at(nostr::Timestamp::from(1000))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let e_new = EventBuilder::new(Kind::Metadata, r#"{"display_name":"New"}"#)
|
||||
.custom_created_at(nostr::Timestamp::from(2000))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let pk = keys.public_key().to_hex();
|
||||
let other_pk = e1.pubkey.to_hex();
|
||||
|
||||
let missing_pk = "f".repeat(64);
|
||||
let resp = users_batch_from_events(
|
||||
&[e1, e_old, e_new],
|
||||
&[pk.clone(), other_pk.clone(), missing_pk.clone()],
|
||||
);
|
||||
assert_eq!(resp.profiles.len(), 2);
|
||||
assert_eq!(resp.profiles[&pk].display_name.as_deref(), Some("New"));
|
||||
assert_eq!(resp.missing, vec![missing_pk]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn users_batch_marks_valid_nip_oa_profiles_as_agents() {
|
||||
let (agent, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#);
|
||||
let pubkey = agent.pubkey.to_hex();
|
||||
let resp = users_batch_from_events(std::slice::from_ref(&agent), std::slice::from_ref(&pubkey));
|
||||
|
||||
assert!(resp.profiles[&pubkey].is_agent);
|
||||
assert_eq!(
|
||||
resp.profiles[&pubkey].owner_pubkey.as_deref(),
|
||||
Some(owner_pubkey.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_notes_builds_cursor_from_last() {
|
||||
let e1 = ev(1, "first", vec![]);
|
||||
let e2 = ev(1, "second", vec![]);
|
||||
let r = user_notes_from_events(&[e1, e2]);
|
||||
assert_eq!(r.notes.len(), 2);
|
||||
assert_eq!(r.notes[0].content, "first");
|
||||
let cursor = r.next_cursor.expect("cursor");
|
||||
assert_eq!(cursor.before_id, r.notes[1].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_notes_empty_has_no_cursor() {
|
||||
let r = user_notes_from_events(&[]);
|
||||
assert!(r.notes.is_empty());
|
||||
assert!(r.next_cursor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_list_preserves_tags_and_content() {
|
||||
let pk = "1".repeat(64);
|
||||
let e = ev(3, "rel-json", vec![vec!["p", &pk]]);
|
||||
let r = contact_list_from_event(&e).unwrap();
|
||||
assert_eq!(r.content, "rel-json");
|
||||
assert_eq!(r.tags.len(), 1);
|
||||
assert_eq!(r.tags[0], vec!["p".to_string(), pk]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_response_assigns_descending_scores() {
|
||||
let e1 = ev(1, "one", vec![vec!["h", "chan"]]);
|
||||
let e2 = ev(1, "two", vec![]);
|
||||
let r = search_response_from_events(&[e1, e2]);
|
||||
assert_eq!(r.found, 2);
|
||||
assert!(r.hits[0].score > r.hits[1].score);
|
||||
assert_eq!(r.hits[0].channel_id.as_deref(), Some("chan"));
|
||||
assert!(r.hits[1].channel_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_response_single_hit_full_score() {
|
||||
let e = ev(1, "only", vec![]);
|
||||
let r = search_response_from_events(&[e]);
|
||||
assert_eq!(r.hits.len(), 1);
|
||||
assert_eq!(r.hits[0].score, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_overwrites_pubkey_from_event_author() {
|
||||
let e = ev(10100, r#"{"pubkey":"forged","name":"agent-1"}"#, vec![]);
|
||||
let v = agents_from_events(std::slice::from_ref(&e));
|
||||
let arr = v.get("agents").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(
|
||||
arr[0].get("pubkey").and_then(Value::as_str).unwrap(),
|
||||
e.pubkey.to_hex()
|
||||
);
|
||||
assert_eq!(arr[0].get("name").and_then(Value::as_str), Some("agent-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_handles_invalid_content() {
|
||||
let e = ev(10100, "not-json", vec![]);
|
||||
let v = agents_from_events(std::slice::from_ref(&e));
|
||||
let arr = v.get("agents").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(
|
||||
arr[0].get("pubkey").and_then(Value::as_str).unwrap(),
|
||||
e.pubkey.to_hex()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_default_sparse_agent_profiles_for_directory_parse() {
|
||||
let e = ev(
|
||||
10100,
|
||||
r#"{"channel_add_policy":"owner-only","display_name":"Scout"}"#,
|
||||
vec![],
|
||||
);
|
||||
let v = agents_from_events(std::slice::from_ref(&e));
|
||||
let agents = v.get("agents").cloned().unwrap();
|
||||
let parsed: Vec<crate::managed_agents::RelayAgentInfo> =
|
||||
serde_json::from_value(agents).unwrap();
|
||||
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].pubkey, e.pubkey.to_hex());
|
||||
assert_eq!(parsed[0].name, "Scout");
|
||||
assert_eq!(parsed[0].agent_type, "agent");
|
||||
assert_eq!(parsed[0].channels, Vec::<String>::new());
|
||||
assert_eq!(parsed[0].capabilities, Vec::<String>::new());
|
||||
assert_eq!(parsed[0].status, "offline");
|
||||
assert_eq!(parsed[0].respond_to, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_preserves_public_respond_to_mode_for_directory_parse() {
|
||||
let e = ev(10100, r#"{"name":"Scout","respond_to":"anyone"}"#, vec![]);
|
||||
let v = agents_from_events(std::slice::from_ref(&e));
|
||||
let agents = v.get("agents").cloned().unwrap();
|
||||
let parsed: Vec<crate::managed_agents::RelayAgentInfo> =
|
||||
serde_json::from_value(agents).unwrap();
|
||||
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(
|
||||
parsed[0].respond_to,
|
||||
Some(crate::managed_agents::RespondTo::Anyone)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_preserves_allowlist_metadata_for_directory_parse() {
|
||||
let e = ev(
|
||||
10100,
|
||||
r#"{"name":"Scout","respond_to":"allowlist","respond_to_allowlist":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#,
|
||||
vec![],
|
||||
);
|
||||
let v = agents_from_events(std::slice::from_ref(&e));
|
||||
let agents = v.get("agents").cloned().unwrap();
|
||||
let parsed: Vec<crate::managed_agents::RelayAgentInfo> =
|
||||
serde_json::from_value(agents).unwrap();
|
||||
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(
|
||||
parsed[0].respond_to,
|
||||
Some(crate::managed_agents::RespondTo::Allowlist)
|
||||
);
|
||||
assert_eq!(parsed[0].respond_to_allowlist, vec!["a".repeat(64)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_agent_directory_accepts_only_the_verified_owner_policy() {
|
||||
let agent_keys = Keys::generate();
|
||||
let owner_keys = Keys::generate();
|
||||
let attacker_keys = Keys::generate();
|
||||
let agent_pubkey = agent_keys.public_key().to_hex();
|
||||
let viewer_pubkey = "a".repeat(64);
|
||||
|
||||
let auth_tag_json =
|
||||
buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "")
|
||||
.expect("compute auth tag");
|
||||
let auth_tag_values: Vec<String> =
|
||||
serde_json::from_str(&auth_tag_json).expect("parse auth tag json");
|
||||
let profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#)
|
||||
.tags([Tag::parse(auth_tag_values).expect("parse auth tag")])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.expect("sign profile");
|
||||
let authentic = managed_agent_event(
|
||||
&owner_keys,
|
||||
&agent_pubkey,
|
||||
"Codex",
|
||||
"allowlist",
|
||||
std::slice::from_ref(&viewer_pubkey),
|
||||
);
|
||||
let forged = managed_agent_event(&attacker_keys, &agent_pubkey, "Fake Codex", "anyone", &[]);
|
||||
|
||||
let agents = relay_agents_from_managed_agent_events(
|
||||
&[forged, authentic],
|
||||
std::slice::from_ref(&profile),
|
||||
);
|
||||
|
||||
assert_eq!(agents.len(), 1);
|
||||
assert_eq!(agents[0].pubkey, agent_pubkey);
|
||||
assert_eq!(agents[0].name, "Codex");
|
||||
assert_eq!(
|
||||
agents[0].respond_to,
|
||||
Some(crate::managed_agents::RespondTo::Allowlist)
|
||||
);
|
||||
assert_eq!(agents[0].respond_to_allowlist, vec![viewer_pubkey]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_agent_directory_rejects_agents_without_verified_owner_profiles() {
|
||||
let owner_keys = Keys::generate();
|
||||
let unverified_agent_keys = Keys::generate();
|
||||
let agent_pubkey = unverified_agent_keys.public_key().to_hex();
|
||||
let profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#)
|
||||
.sign_with_keys(&unverified_agent_keys)
|
||||
.expect("sign profile");
|
||||
let managed = managed_agent_event(&owner_keys, &agent_pubkey, "Codex", "anyone", &[]);
|
||||
|
||||
let agents = relay_agents_from_managed_agent_events(
|
||||
std::slice::from_ref(&managed),
|
||||
std::slice::from_ref(&profile),
|
||||
);
|
||||
|
||||
assert!(agents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_agent_directory_uses_the_latest_profile_head() {
|
||||
let agent_keys = Keys::generate();
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_pubkey = agent_keys.public_key().to_hex();
|
||||
let auth_tag_json =
|
||||
buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "")
|
||||
.expect("compute auth tag");
|
||||
let auth_tag_values: Vec<String> =
|
||||
serde_json::from_str(&auth_tag_json).expect("parse auth tag json");
|
||||
let verified_profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#)
|
||||
.tags([Tag::parse(auth_tag_values).expect("parse auth tag")])
|
||||
.custom_created_at(nostr::Timestamp::from(10))
|
||||
.sign_with_keys(&agent_keys)
|
||||
.expect("sign verified profile");
|
||||
let revoked_profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#)
|
||||
.custom_created_at(nostr::Timestamp::from(20))
|
||||
.sign_with_keys(&agent_keys)
|
||||
.expect("sign revoked profile");
|
||||
let managed = managed_agent_event(&owner_keys, &agent_pubkey, "Codex", "anyone", &[]);
|
||||
|
||||
let agents = relay_agents_from_managed_agent_events(
|
||||
std::slice::from_ref(&managed),
|
||||
&[verified_profile, revoked_profile],
|
||||
);
|
||||
|
||||
assert!(agents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_agent_candidates_use_only_relay_signed_bot_membership() {
|
||||
let relay_keys = Keys::generate();
|
||||
let agent_pubkey = Keys::generate().public_key().to_hex();
|
||||
let stranger = Keys::generate().public_key().to_hex();
|
||||
let general = EventBuilder::new(Kind::Custom(39002), "")
|
||||
.tags([
|
||||
Tag::parse(["d", "family"]).expect("parse d tag"),
|
||||
Tag::parse(["p", &agent_pubkey, "", "bot"]).expect("parse agent tag"),
|
||||
Tag::parse(["p", &stranger, "", "member"]).expect("parse member tag"),
|
||||
])
|
||||
.sign_with_keys(&relay_keys)
|
||||
.expect("sign membership");
|
||||
let forged = ev(
|
||||
39002,
|
||||
"",
|
||||
vec![vec!["d", "forged"], vec!["p", &agent_pubkey, "", "bot"]],
|
||||
);
|
||||
|
||||
let channel_ids =
|
||||
member_agent_channel_ids_from_events(&[forged, general], &relay_keys.public_key().to_hex());
|
||||
|
||||
assert_eq!(
|
||||
channel_ids.get(&agent_pubkey),
|
||||
Some(&vec!["family".to_string()])
|
||||
);
|
||||
assert!(!channel_ids.contains_key(&stranger));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_agent_directory_query_pubkeys_reject_malformed_d_tags() {
|
||||
let valid_pubkey = Keys::generate().public_key().to_hex();
|
||||
let valid = ev(30177, "{}", vec![vec!["d", &valid_pubkey]]);
|
||||
let malformed = ev(30177, "{}", vec![vec!["d", "not-a-pubkey"]]);
|
||||
|
||||
let pubkeys = managed_agent_pubkeys_from_events(&[malformed, valid]);
|
||||
|
||||
assert_eq!(pubkeys, [valid_pubkey].into_iter().collect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_agent_directory_preserves_headless_profiles_and_prefers_verified_managed_policy() {
|
||||
let owner_keys = Keys::generate();
|
||||
let managed_agent_keys = Keys::generate();
|
||||
let managed_pubkey = managed_agent_keys.public_key().to_hex();
|
||||
let headless_keys = Keys::generate();
|
||||
let headless_pubkey = headless_keys.public_key().to_hex();
|
||||
let viewer_pubkey = "a".repeat(64);
|
||||
|
||||
let headless_profile = EventBuilder::new(
|
||||
Kind::Custom(10100),
|
||||
serde_json::json!({
|
||||
"name": "Headless",
|
||||
"respond_to": "anyone",
|
||||
"channel_ids": ["untrusted-channel"]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.sign_with_keys(&headless_keys)
|
||||
.expect("sign headless directory profile");
|
||||
let stale_managed_profile = EventBuilder::new(
|
||||
Kind::Custom(10100),
|
||||
serde_json::json!({
|
||||
"name": "Stale Codex",
|
||||
"respond_to": "anyone"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.sign_with_keys(&managed_agent_keys)
|
||||
.expect("sign managed directory profile");
|
||||
|
||||
let auth_tag_json =
|
||||
buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &managed_agent_keys.public_key(), "")
|
||||
.expect("compute auth tag");
|
||||
let auth_tag_values: Vec<String> =
|
||||
serde_json::from_str(&auth_tag_json).expect("parse auth tag json");
|
||||
let managed_identity = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#)
|
||||
.tags([Tag::parse(auth_tag_values).expect("parse auth tag")])
|
||||
.sign_with_keys(&managed_agent_keys)
|
||||
.expect("sign managed profile");
|
||||
let managed_policy = managed_agent_event(
|
||||
&owner_keys,
|
||||
&managed_pubkey,
|
||||
"Codex",
|
||||
"allowlist",
|
||||
std::slice::from_ref(&viewer_pubkey),
|
||||
);
|
||||
|
||||
let agents = relay_agents_from_directory_events(
|
||||
&[headless_profile, stale_managed_profile],
|
||||
std::slice::from_ref(&managed_policy),
|
||||
std::slice::from_ref(&managed_identity),
|
||||
);
|
||||
|
||||
assert_eq!(agents.len(), 2);
|
||||
let headless = agents
|
||||
.iter()
|
||||
.find(|agent| agent.pubkey == headless_pubkey)
|
||||
.expect("headless profile retained");
|
||||
assert_eq!(
|
||||
headless.respond_to,
|
||||
Some(crate::managed_agents::RespondTo::Anyone)
|
||||
);
|
||||
assert!(
|
||||
headless.channel_ids.is_empty(),
|
||||
"claimed channel ids are not trusted"
|
||||
);
|
||||
|
||||
let managed = agents
|
||||
.iter()
|
||||
.find(|agent| agent.pubkey == managed_pubkey)
|
||||
.expect("managed profile retained");
|
||||
assert_eq!(managed.name, "Codex");
|
||||
assert_eq!(
|
||||
managed.respond_to,
|
||||
Some(crate::managed_agents::RespondTo::Allowlist)
|
||||
);
|
||||
assert_eq!(managed.respond_to_allowlist, vec![viewer_pubkey]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_malformed_managed_policy_does_not_fall_back_to_legacy_permissions() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let agent_pubkey = agent_keys.public_key().to_hex();
|
||||
let legacy = EventBuilder::new(
|
||||
Kind::Custom(10100),
|
||||
r#"{"name":"Stale","respond_to":"anyone"}"#,
|
||||
)
|
||||
.sign_with_keys(&agent_keys)
|
||||
.expect("sign legacy profile");
|
||||
let auth_tag_json =
|
||||
buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "")
|
||||
.expect("compute auth tag");
|
||||
let auth_tag_values: Vec<String> =
|
||||
serde_json::from_str(&auth_tag_json).expect("parse auth tag json");
|
||||
let profile = EventBuilder::new(Kind::Metadata, "{}")
|
||||
.tags([Tag::parse(auth_tag_values).expect("parse auth tag")])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.expect("sign profile");
|
||||
let malformed = EventBuilder::new(
|
||||
Kind::Custom(30177),
|
||||
r#"{"name":"Current","parallelism":1,"respond_to":"future-mode"}"#,
|
||||
)
|
||||
.tags([Tag::parse(["d", &agent_pubkey]).expect("parse d tag")])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.expect("sign managed policy");
|
||||
|
||||
let agents = relay_agents_from_directory_events(&[legacy], &[malformed], &[profile]);
|
||||
|
||||
assert!(agents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_agent_directory_resolves_equal_timestamp_heads_by_event_id() {
|
||||
let keys = Keys::generate();
|
||||
let timestamp = nostr::Timestamp::from(42);
|
||||
let first = EventBuilder::new(
|
||||
Kind::Custom(10100),
|
||||
r#"{"name":"First","respond_to":"anyone"}"#,
|
||||
)
|
||||
.custom_created_at(timestamp)
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign first directory head");
|
||||
let second = EventBuilder::new(
|
||||
Kind::Custom(10100),
|
||||
r#"{"name":"Second","respond_to":"anyone"}"#,
|
||||
)
|
||||
.custom_created_at(timestamp)
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign second directory head");
|
||||
let expected_name = if first.id < second.id {
|
||||
"First"
|
||||
} else {
|
||||
"Second"
|
||||
};
|
||||
|
||||
let forward = relay_agents_from_directory_events(&[first.clone(), second.clone()], &[], &[]);
|
||||
let reverse = relay_agents_from_directory_events(&[second, first], &[], &[]);
|
||||
|
||||
assert_eq!(forward.len(), 1);
|
||||
assert_eq!(reverse.len(), 1);
|
||||
assert_eq!(forward[0].name, expected_name);
|
||||
assert_eq!(reverse[0].name, expected_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_managed_policy_cannot_suppress_a_headless_directory_agent() {
|
||||
let attacker_keys = Keys::generate();
|
||||
let targeted_agent_keys = Keys::generate();
|
||||
let targeted_pubkey = targeted_agent_keys.public_key().to_hex();
|
||||
let headless_keys = Keys::generate();
|
||||
let headless_pubkey = headless_keys.public_key().to_hex();
|
||||
let targeted_profile = EventBuilder::new(
|
||||
Kind::Custom(10100),
|
||||
r#"{"name":"Targeted","respond_to":"anyone"}"#,
|
||||
)
|
||||
.sign_with_keys(&targeted_agent_keys)
|
||||
.expect("sign targeted profile");
|
||||
let headless = EventBuilder::new(
|
||||
Kind::Custom(10100),
|
||||
r#"{"name":"Headless","respond_to":"anyone"}"#,
|
||||
)
|
||||
.sign_with_keys(&headless_keys)
|
||||
.expect("sign headless profile");
|
||||
let forged_policy = managed_agent_event(
|
||||
&attacker_keys,
|
||||
&targeted_pubkey,
|
||||
"Codex",
|
||||
"allowlist",
|
||||
&["a".repeat(64)],
|
||||
);
|
||||
|
||||
let agents = relay_agents_from_directory_events(
|
||||
&[targeted_profile, headless],
|
||||
std::slice::from_ref(&forged_policy),
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(agents.len(), 2);
|
||||
assert!(agents.iter().any(|agent| agent.pubkey == targeted_pubkey));
|
||||
assert!(agents.iter().any(|agent| agent.pubkey == headless_pubkey));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_members_dedupes_and_defaults_role() {
|
||||
let pk1 = "a".repeat(64);
|
||||
let pk2 = "b".repeat(64);
|
||||
// Current relay format: ["member", pubkey, role]
|
||||
let e = ev(
|
||||
13534,
|
||||
"",
|
||||
vec![
|
||||
vec!["member", &pk1, "owner"],
|
||||
vec!["member", &pk2],
|
||||
vec!["member", &pk1, "moderator"], // dupe — ignored
|
||||
],
|
||||
);
|
||||
let v = relay_members_from_event(&e);
|
||||
let arr = v.get("members").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("owner"));
|
||||
assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_members_fallback_p_tags() {
|
||||
let pk1 = "a".repeat(64);
|
||||
let pk2 = "b".repeat(64);
|
||||
// Legacy/fallback format: ["p", pubkey, relay_url?, role?]
|
||||
let e = ev(
|
||||
13534,
|
||||
"",
|
||||
vec![vec!["p", &pk1, "", "admin"], vec!["p", &pk2]],
|
||||
);
|
||||
let v = relay_members_from_event(&e);
|
||||
let arr = v.get("members").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("admin"));
|
||||
assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_to_iso_known_value() {
|
||||
// 2021-01-01T00:00:00Z = 1609459200
|
||||
assert_eq!(timestamp_to_iso(1_609_459_200), "2021-01-01T00:00:00Z");
|
||||
// Epoch
|
||||
assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z");
|
||||
}
|
||||
@@ -160,8 +160,12 @@ with a TypeScript lookup table or an id comparison in a component.
|
||||
computer, including files, accounts, and connected tools"; remote names "the
|
||||
server it runs on, including any accounts and tools available there" —
|
||||
deliberately *not* the owner's files, which aren't theirs to describe on a
|
||||
host they don't own. **An unknown location falls back to the local wording —
|
||||
never hedge with "computer or server".** A remote host requires an
|
||||
host they don't own. **For a persona-linked deployed agent, the profile Edit
|
||||
dialog seeds access from the exact clicked instance and saves access through
|
||||
`update_managed_agent`; persona behavior remains the definition default, but
|
||||
must never bypass the instance command's stop, persist, publish, and restart
|
||||
boundary.** An unknown location falls back to the local wording — never hedge
|
||||
with "computer or server". A remote host requires an
|
||||
installed `buzz-backend-*` provider, and without one `WhereToRunSection`
|
||||
never renders, so "server" would name a concept the owner has never been
|
||||
shown; when it *is* remote they picked that host from the selector
|
||||
|
||||
@@ -341,14 +341,10 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: relayAgentsQueryKey,
|
||||
queryFn: listRelayAgents,
|
||||
// Relay agent profiles (kind:10100) are near-static and the backing
|
||||
// `list_relay_agents` command is an unfiltered relay query for the whole
|
||||
// profile set — mounted on ~13 always-live surfaces (channel screen,
|
||||
// members bar, mentions, sidebar, profile popovers), so a tight interval
|
||||
// re-pulls the full set app-wide. This poll is also the ONLY refresh path:
|
||||
// the `agents-data-changed` event fires only for local persona/team/managed
|
||||
// reconcile (kinds PERSONA/TEAM/MANAGED_AGENT), never for kind:10100. So we
|
||||
// keep polling but at a relaxed cadence and pause it while backgrounded.
|
||||
// Relay agent discovery is scoped to the viewer's relay-signed channel
|
||||
// memberships, then resolves exact agent/profile/policy coordinates in
|
||||
// protocol-sized batches. Polling remains the only refresh path for remote
|
||||
// changes, so keep it relaxed and pause while backgrounded.
|
||||
refetchInterval,
|
||||
enabled: options?.enabled,
|
||||
...agentsFocusRefetchPolicy,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { pickProfileAgent } from "./pickProfileAgent.ts";
|
||||
import {
|
||||
pickDirectProfileAgent,
|
||||
pickProfileAgent,
|
||||
} from "./pickProfileAgent.ts";
|
||||
|
||||
test("the shared profile target prefers the active persona instance", () => {
|
||||
const stopped = {
|
||||
@@ -18,3 +21,57 @@ test("the shared profile target prefers the active persona instance", () => {
|
||||
assert.equal(pickProfileAgent([stopped, running]), running);
|
||||
assert.equal(pickProfileAgent([running, stopped]), running);
|
||||
});
|
||||
|
||||
test("a direct-opened active instance is never redirected to a sibling", () => {
|
||||
// "Alpha Sibling" sorts before "Tyler Agent"; without the direct guard an
|
||||
// access edit on Tyler would target the sibling.
|
||||
const sibling = {
|
||||
name: "Alpha Sibling",
|
||||
pubkey: "a".repeat(64),
|
||||
status: "running",
|
||||
};
|
||||
const clicked = {
|
||||
name: "Tyler Agent",
|
||||
pubkey: "b".repeat(64),
|
||||
status: "running",
|
||||
};
|
||||
|
||||
assert.equal(pickDirectProfileAgent(clicked, [sibling, clicked]), clicked);
|
||||
});
|
||||
|
||||
test("a direct-opened inactive instance redirects to the active sibling", () => {
|
||||
const historical = {
|
||||
name: "Earlier Parity Agent",
|
||||
pubkey: "a".repeat(64),
|
||||
status: "stopped",
|
||||
};
|
||||
const current = {
|
||||
name: "Current Parity Agent",
|
||||
pubkey: "b".repeat(64),
|
||||
status: "running",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
pickDirectProfileAgent(historical, [historical, current]),
|
||||
current,
|
||||
);
|
||||
});
|
||||
|
||||
test("a direct-opened inactive instance with no active sibling stays put", () => {
|
||||
const clicked = {
|
||||
name: "Only Instance",
|
||||
pubkey: "a".repeat(64),
|
||||
status: "stopped",
|
||||
};
|
||||
const otherStopped = {
|
||||
name: "Another Stopped",
|
||||
pubkey: "b".repeat(64),
|
||||
status: "stopped",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
pickDirectProfileAgent(clicked, [clicked, otherStopped]),
|
||||
clicked,
|
||||
);
|
||||
assert.equal(pickDirectProfileAgent(clicked, []), clicked);
|
||||
});
|
||||
|
||||
@@ -16,3 +16,23 @@ export function pickProfileAgent(agents: readonly ManagedAgent[]) {
|
||||
return left.name.localeCompare(right.name);
|
||||
})[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which instance a profile panel opened for `directAgent` should
|
||||
* show, given every instance of the same persona.
|
||||
*
|
||||
* Access edits must target the exact instance the user clicked — resolving a
|
||||
* running sidebar member to an alphabetically-earlier sibling would let a
|
||||
* "tighten access" save widen the wrong agent. But when the clicked instance
|
||||
* is inactive and the persona has an active instance elsewhere (an avatar on
|
||||
* an old message from a retired instance), redirect to the active one so the
|
||||
* panel matches the Agents library.
|
||||
*/
|
||||
export function pickDirectProfileAgent(
|
||||
directAgent: ManagedAgent,
|
||||
personaInstances: readonly ManagedAgent[],
|
||||
) {
|
||||
if (isManagedAgentActive(directAgent)) return directAgent;
|
||||
const canonical = pickProfileAgent(personaInstances);
|
||||
return canonical && isManagedAgentActive(canonical) ? canonical : directAgent;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds";
|
||||
import { startRelayAgentPolicyRefresh } from "./useAgentsDataRefresh.ts";
|
||||
|
||||
const coordinates = [
|
||||
{ ownerPubkey: "owner-a", agentPubkey: "agent-a" },
|
||||
{ ownerPubkey: "owner-b", agentPubkey: "agent-b" },
|
||||
];
|
||||
|
||||
function event(pubkey, dTag) {
|
||||
return {
|
||||
id: "id",
|
||||
pubkey,
|
||||
created_at: 1,
|
||||
kind: KIND_MANAGED_AGENT,
|
||||
tags: dTag ? [["d", dTag]] : [],
|
||||
content: "{}",
|
||||
sig: "sig",
|
||||
};
|
||||
}
|
||||
|
||||
test("remote managed policy refresh accepts only exact authenticated coordinates", async () => {
|
||||
let onEvent;
|
||||
let filter;
|
||||
let unsubscribeCalls = 0;
|
||||
mock.method(relayClient, "subscribeLive", (nextFilter, listener) => {
|
||||
filter = nextFilter;
|
||||
onEvent = listener;
|
||||
return Promise.resolve(() => {
|
||||
unsubscribeCalls += 1;
|
||||
return Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
let refreshes = 0;
|
||||
const stop = startRelayAgentPolicyRefresh(coordinates, () => {
|
||||
refreshes += 1;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(filter, {
|
||||
kinds: [KIND_MANAGED_AGENT],
|
||||
authors: ["owner-a", "owner-b"],
|
||||
"#d": ["agent-a", "agent-b"],
|
||||
limit: 0,
|
||||
});
|
||||
onEvent(event("owner-a", "agent-a"));
|
||||
assert.equal(refreshes, 1);
|
||||
|
||||
for (const irrelevant of [
|
||||
event("owner-x", "agent-a"),
|
||||
event("owner-a", "agent-x"),
|
||||
event("owner-a", "agent-b"), // authors×d cross-product
|
||||
event("owner-a", null),
|
||||
]) {
|
||||
onEvent(irrelevant);
|
||||
}
|
||||
assert.equal(refreshes, 1, "irrelevant coordinates must not refresh");
|
||||
|
||||
stop();
|
||||
assert.equal(unsubscribeCalls, 1);
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
test("stopping before subscription readiness still closes the live query", async () => {
|
||||
let resolveSubscription;
|
||||
let unsubscribeCalls = 0;
|
||||
mock.method(
|
||||
relayClient,
|
||||
"subscribeLive",
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSubscription = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const stop = startRelayAgentPolicyRefresh(coordinates, () => {});
|
||||
stop();
|
||||
resolveSubscription(() => {
|
||||
unsubscribeCalls += 1;
|
||||
return Promise.resolve();
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(unsubscribeCalls, 1);
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
test("no authenticated coordinates creates no global subscription", () => {
|
||||
let subscriptions = 0;
|
||||
mock.method(relayClient, "subscribeLive", () => {
|
||||
subscriptions += 1;
|
||||
return Promise.resolve(() => Promise.resolve());
|
||||
});
|
||||
startRelayAgentPolicyRefresh([], () => {})();
|
||||
assert.equal(subscriptions, 0);
|
||||
mock.reset();
|
||||
});
|
||||
@@ -2,6 +2,9 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { RelayAgent, RelayEvent } from "@/shared/api/types";
|
||||
import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds";
|
||||
import {
|
||||
managedAgentsQueryKey,
|
||||
personasQueryKey,
|
||||
@@ -10,18 +13,84 @@ import {
|
||||
} from "@/features/agents/hooks";
|
||||
import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks";
|
||||
|
||||
// Trailing-coalesce window: a backfill burst (up to 500 inbound events fed
|
||||
// one-by-one through reconcile) fires one `agents-data-changed` per event.
|
||||
// Collapsing them into a single invalidate after the burst settles keeps the
|
||||
// refetch off React Query's implicit in-flight dedup and avoids redundant
|
||||
// disk-read IPC.
|
||||
const COALESCE_MS = 200;
|
||||
export const RELAY_POLICY_REFRESH_MIN_INTERVAL_MS = 5_000;
|
||||
|
||||
export type RelayAgentPolicyCoordinate = {
|
||||
agentPubkey: string;
|
||||
ownerPubkey: string;
|
||||
};
|
||||
|
||||
function eventDTag(event: RelayEvent): string | null {
|
||||
return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe only to authenticated managed-agent coordinates already returned by
|
||||
* the relay directory. The callback repeats the exact owner+d check because a
|
||||
* combined Nostr filter admits the authors×d cross-product.
|
||||
*/
|
||||
export function startRelayAgentPolicyRefresh(
|
||||
coordinates: RelayAgentPolicyCoordinate[],
|
||||
onChange: () => void,
|
||||
onError: (error: unknown) => void = (error) => {
|
||||
console.warn("Couldn’t subscribe to managed agent policy updates", error);
|
||||
},
|
||||
): () => void {
|
||||
if (coordinates.length === 0) return () => {};
|
||||
|
||||
const allowed = new Set(
|
||||
coordinates.map(
|
||||
({ ownerPubkey, agentPubkey }) =>
|
||||
`${ownerPubkey.toLowerCase()}:${agentPubkey.toLowerCase()}`,
|
||||
),
|
||||
);
|
||||
const authors = [
|
||||
...new Set(coordinates.map(({ ownerPubkey }) => ownerPubkey)),
|
||||
];
|
||||
const agentPubkeys = [
|
||||
...new Set(coordinates.map(({ agentPubkey }) => agentPubkey)),
|
||||
];
|
||||
let disposed = false;
|
||||
let unsubscribe: (() => Promise<void>) | null = null;
|
||||
void relayClient
|
||||
.subscribeLive(
|
||||
{
|
||||
kinds: [KIND_MANAGED_AGENT],
|
||||
authors,
|
||||
"#d": agentPubkeys,
|
||||
limit: 0,
|
||||
},
|
||||
(event) => {
|
||||
const dTag = eventDTag(event);
|
||||
if (
|
||||
dTag &&
|
||||
allowed.has(`${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`)
|
||||
) {
|
||||
onChange();
|
||||
}
|
||||
},
|
||||
)
|
||||
.then((nextUnsubscribe) => {
|
||||
if (disposed) void nextUnsubscribe();
|
||||
else unsubscribe = nextUnsubscribe;
|
||||
})
|
||||
.catch(onError);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
void unsubscribe?.();
|
||||
};
|
||||
}
|
||||
|
||||
function relayPolicyCoordinates(agents: RelayAgent[] | undefined) {
|
||||
return (agents ?? []).flatMap((agent) =>
|
||||
agent.ownerPubkey
|
||||
? [{ agentPubkey: agent.pubkey, ownerPubkey: agent.ownerPubkey }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate the live Agents-tab queries when the backend signals that inbound
|
||||
// relay events changed the on-disk agents data. Mounted once at the app root
|
||||
// with empty deps — invalidation is global and has no reason to be
|
||||
// pubkey-scoped, so it must NOT live inside the pubkey-keyed `usePersonaSync`
|
||||
// (re-registering per identity switch would leak a listener each time).
|
||||
export function useAgentsDataRefresh(): void {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -32,8 +101,6 @@ export function useAgentsDataRefresh(): void {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: managedAgentRuntimesQueryKey,
|
||||
});
|
||||
// Pair startup also changes the legacy managed-agent scalar status.
|
||||
// Keep that cache synchronized for consumers outside pair-runtime UI.
|
||||
void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
|
||||
});
|
||||
|
||||
@@ -47,10 +114,73 @@ export function useAgentsDataRefresh(): void {
|
||||
}, COALESCE_MS);
|
||||
});
|
||||
|
||||
let policyStop = () => {};
|
||||
let policyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let policyDirty = false;
|
||||
let policyRefreshInFlight = false;
|
||||
let policyDisposed = false;
|
||||
let coordinateKey = "";
|
||||
|
||||
const refreshPolicyDirectory = () => {
|
||||
if (policyRefreshInFlight || policyTimer !== undefined) {
|
||||
policyDirty = true;
|
||||
return;
|
||||
}
|
||||
policyRefreshInFlight = true;
|
||||
void queryClient
|
||||
.invalidateQueries({ queryKey: relayAgentsQueryKey })
|
||||
.finally(() => {
|
||||
policyRefreshInFlight = false;
|
||||
if (policyDisposed) return;
|
||||
policyTimer = setTimeout(() => {
|
||||
policyTimer = undefined;
|
||||
if (policyDirty) {
|
||||
policyDirty = false;
|
||||
refreshPolicyDirectory();
|
||||
}
|
||||
}, RELAY_POLICY_REFRESH_MIN_INTERVAL_MS);
|
||||
});
|
||||
};
|
||||
|
||||
const resubscribePolicy = () => {
|
||||
const coordinates = relayPolicyCoordinates(
|
||||
queryClient.getQueryData<RelayAgent[]>(relayAgentsQueryKey),
|
||||
);
|
||||
const nextKey = coordinates
|
||||
.map(({ ownerPubkey, agentPubkey }) => `${ownerPubkey}:${agentPubkey}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
if (nextKey === coordinateKey) return;
|
||||
coordinateKey = nextKey;
|
||||
policyStop();
|
||||
policyStop = startRelayAgentPolicyRefresh(
|
||||
coordinates,
|
||||
refreshPolicyDirectory,
|
||||
);
|
||||
};
|
||||
resubscribePolicy();
|
||||
const unsubscribeQueryCache = queryClient
|
||||
.getQueryCache()
|
||||
.subscribe((event) => {
|
||||
if (
|
||||
event.query.queryKey.length === relayAgentsQueryKey.length &&
|
||||
event.query.queryKey.every(
|
||||
(value: unknown, index: number) =>
|
||||
value === relayAgentsQueryKey[index],
|
||||
)
|
||||
) {
|
||||
resubscribePolicy();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
policyDisposed = true;
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
if (policyTimer !== undefined) clearTimeout(policyTimer);
|
||||
void unlisten.then((fn) => fn());
|
||||
void unlistenRuntime.then((fn) => fn());
|
||||
unsubscribeQueryCache();
|
||||
policyStop();
|
||||
};
|
||||
}, [queryClient]);
|
||||
}
|
||||
|
||||
@@ -108,3 +108,46 @@ test("startPersonaSync forwards its own relay as the event arrival relay", async
|
||||
mock.reset();
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
test("startPersonaSync serializes inbound reconciliation in relay order", async () => {
|
||||
const resolvers = [];
|
||||
const invokedIds = [];
|
||||
globalThis.window = {
|
||||
__TAURI_INTERNALS__: {
|
||||
invoke: (_cmd, args) => {
|
||||
invokedIds.push(JSON.parse(args.eventJson).id);
|
||||
return new Promise((resolve) => resolvers.push(resolve));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let onEvent;
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "subscribeLive", (_filter, listener) => {
|
||||
onEvent = listener;
|
||||
return Promise.resolve(() => Promise.resolve());
|
||||
});
|
||||
|
||||
startPersonaSync("owner-pubkey", "wss://community.example", () => false);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
onEvent({ id: "broad", pubkey: "owner-pubkey", kind: KIND_MANAGED_AGENT });
|
||||
onEvent({
|
||||
id: "restricted",
|
||||
pubkey: "owner-pubkey",
|
||||
kind: KIND_MANAGED_AGENT,
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(
|
||||
invokedIds,
|
||||
["broad"],
|
||||
"newer event waits for prior deployment",
|
||||
);
|
||||
resolvers.shift()();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(invokedIds, ["broad", "restricted"]);
|
||||
resolvers.shift()();
|
||||
|
||||
mock.reset();
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
@@ -35,13 +35,19 @@ export function startPersonaSync(
|
||||
relayUrl: string,
|
||||
onCancelled: () => boolean,
|
||||
): () => Promise<void> {
|
||||
// Reconcile in relay order. Managed-agent reconciliation can await a remote
|
||||
// provider deployment after releasing the local store lock; firing commands
|
||||
// independently lets an older broad policy finish after a newer restrictive
|
||||
// one. One chain per owner/relay subscription makes the newest event the last
|
||||
// deployment without serializing unrelated identities or communities.
|
||||
let reconcileChain = Promise.resolve();
|
||||
const reconcile = (event: RelayEvent) => {
|
||||
if (event.pubkey !== pubkey) return;
|
||||
void reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl).catch(
|
||||
(error) => {
|
||||
reconcileChain = reconcileChain
|
||||
.then(() => reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl))
|
||||
.catch((error) => {
|
||||
console.warn("[usePersonaSync] reconcile failed:", error);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// One-shot backfill of existing heads + tombstones (closes the fresh-start
|
||||
|
||||
@@ -484,12 +484,6 @@ export function AgentDefinitionDialog({
|
||||
const modelFieldVisible =
|
||||
runtime.trim().length > 0 || blankRuntimeModelProviderEditable;
|
||||
const isExplicitModelRequired = aiConfigurationMode === "custom";
|
||||
// Gate the provider requirement on the field's actual visibility, not the raw
|
||||
// runtime capability. Codex/Claude hide the provider picker (they drive their
|
||||
// own provider), so Customize must not require a provider there. But a
|
||||
// runtime-less legacy/builtin definition still exposes the picker via
|
||||
// blankRuntimeModelProviderEditable, so it must keep requiring a provider —
|
||||
// otherwise Save could persist `provider: undefined` despite the visible field.
|
||||
const customAiPairSatisfied = agentAiConfigurationModeSatisfied(
|
||||
aiConfigurationMode,
|
||||
{ provider, model },
|
||||
@@ -739,7 +733,6 @@ export function AgentDefinitionDialog({
|
||||
isPending={isPending}
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
publishesCatalogUpdates={publishCatalogUpdatesOnSave && hasUserChanges}
|
||||
submitBlockReason={null}
|
||||
submitLabel={submitLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ type AgentDefinitionDialogFooterProps = {
|
||||
isPending: boolean;
|
||||
onCancel: () => void;
|
||||
publishesCatalogUpdates: boolean;
|
||||
submitBlockReason: string | null;
|
||||
submitLabel: string;
|
||||
};
|
||||
|
||||
@@ -16,20 +15,11 @@ export function AgentDefinitionDialogFooter({
|
||||
isPending,
|
||||
onCancel,
|
||||
publishesCatalogUpdates,
|
||||
submitBlockReason,
|
||||
submitLabel,
|
||||
}: AgentDefinitionDialogFooterProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-h-9 min-w-0 flex-wrap items-center gap-3">
|
||||
{submitBlockReason ? (
|
||||
<p
|
||||
className="text-2xs text-muted-foreground"
|
||||
data-testid="persona-dialog-submit-reason"
|
||||
>
|
||||
{submitBlockReason}
|
||||
</p>
|
||||
) : null}
|
||||
{publishesCatalogUpdates ? (
|
||||
<p
|
||||
className="max-w-sm text-xs text-muted-foreground"
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
agentAiConfigurationModeSatisfied,
|
||||
agentAiConfigurationPairForMode,
|
||||
agentAiConfigurationSubmitBlockReason,
|
||||
initialAgentAiConfigurationMode,
|
||||
} from "./agentAiConfigurationPolicy.ts";
|
||||
|
||||
@@ -50,6 +51,38 @@ test("Customize requires a complete explicit pair", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("incomplete Customize explains why Save remains disabled", () => {
|
||||
assert.equal(
|
||||
agentAiConfigurationSubmitBlockReason("custom", {
|
||||
provider: "",
|
||||
model: "",
|
||||
}),
|
||||
"Choose a provider to save custom AI configuration.",
|
||||
);
|
||||
assert.equal(
|
||||
agentAiConfigurationSubmitBlockReason("custom", {
|
||||
provider: "anthropic",
|
||||
model: "",
|
||||
}),
|
||||
"Choose a model to save custom AI configuration.",
|
||||
);
|
||||
assert.equal(
|
||||
agentAiConfigurationSubmitBlockReason(
|
||||
"custom",
|
||||
{ provider: "", model: "" },
|
||||
false,
|
||||
),
|
||||
"Choose a model to save custom AI configuration.",
|
||||
);
|
||||
assert.equal(
|
||||
agentAiConfigurationSubmitBlockReason("defaults", {
|
||||
provider: "",
|
||||
model: "",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("Codex/Claude Customize needs only a model, not the hidden provider", () => {
|
||||
// needsProviderSelection=false → the intentionally hidden provider must not
|
||||
// gate Save (the create/edit "Save stays disabled" regression).
|
||||
|
||||
@@ -47,6 +47,21 @@ export function agentAiConfigurationPairForMode({
|
||||
* runtime capability, so the gate never diverges from the visible picker. It
|
||||
* defaults to `true` so existing callers keep the provider+model requirement.
|
||||
*/
|
||||
export function agentAiConfigurationSubmitBlockReason(
|
||||
mode: AgentAiConfigurationMode,
|
||||
pair: AgentAiConfigurationPair,
|
||||
needsProviderSelection = true,
|
||||
): string | null {
|
||||
if (
|
||||
mode !== "custom" ||
|
||||
agentAiConfigurationModeSatisfied(mode, pair, needsProviderSelection)
|
||||
)
|
||||
return null;
|
||||
return needsProviderSelection && !pair.provider.trim()
|
||||
? "Choose a provider to save custom AI configuration."
|
||||
: "Choose a model to save custom AI configuration.";
|
||||
}
|
||||
|
||||
export function agentAiConfigurationModeSatisfied(
|
||||
mode: AgentAiConfigurationMode,
|
||||
pair: AgentAiConfigurationPair,
|
||||
|
||||
@@ -6,6 +6,6 @@ export function showAgentProfileSyncWarning(
|
||||
) {
|
||||
if (!profileSyncError) return;
|
||||
toast.warning(
|
||||
`${agentName} was saved, but relay profile sync failed: ${profileSyncError}. The relay may still show the old name — restart the agent to retry the sync.`,
|
||||
`${agentName} was saved locally, but relay sync failed: ${profileSyncError}. Remote users may still see the previous name or access policy until Buzz retries the sync.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -269,6 +269,36 @@ test("edit and duplicate seed the behavior group from a quad-bearing persona", (
|
||||
);
|
||||
});
|
||||
|
||||
test("a linked instance overrides stale definition access in the edit dialog", () => {
|
||||
const persona = {
|
||||
id: "persona-instance-access",
|
||||
displayName: "Shared",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "Shared.",
|
||||
runtime: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
parallelism: 2,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
const state = editPersonaDialogState(persona, {
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: ["c".repeat(64)],
|
||||
});
|
||||
|
||||
assert.deepEqual(state.initialValues.behavior, {
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: ["c".repeat(64)],
|
||||
parallelism: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("a non-allowlist mode does not seed a stale allowlist into the dialog", () => {
|
||||
const state = editPersonaDialogState({
|
||||
id: "persona-mode-flip",
|
||||
|
||||
@@ -104,7 +104,15 @@ function behaviorEntry(
|
||||
|
||||
export function editPersonaDialogState(
|
||||
persona: AgentPersona,
|
||||
accessSource?: Pick<AgentPersona, "respondTo" | "respondToAllowlist">,
|
||||
): PersonaDialogState {
|
||||
const behaviorSource = accessSource
|
||||
? {
|
||||
...persona,
|
||||
respondTo: accessSource.respondTo,
|
||||
respondToAllowlist: accessSource.respondToAllowlist,
|
||||
}
|
||||
: persona;
|
||||
return {
|
||||
title: "Edit agent",
|
||||
description: "",
|
||||
@@ -123,7 +131,7 @@ export function editPersonaDialogState(
|
||||
// the dialog must therefore round-trip the existing values.)
|
||||
namePool: persona.namePool ?? [],
|
||||
envVars: persona.envVars ?? {},
|
||||
...behaviorEntry(persona),
|
||||
...behaviorEntry(behaviorSource),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import { useUpdateManagedAgentMutation } from "@/features/agents/hooks";
|
||||
import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly";
|
||||
import { runLocationForBackend } from "@/features/agents/lib/agentAccessWarning";
|
||||
import { showAgentProfileSyncWarning } from "@/features/agents/ui/agentProfileSyncWarning";
|
||||
import {
|
||||
CreateAgentRespondToField,
|
||||
OWNER_ONLY_ACCESS_DISABLED_REASON,
|
||||
@@ -50,12 +51,13 @@ export function EditRespondToDialog({
|
||||
|
||||
async function handleSave() {
|
||||
if (!agent) return;
|
||||
await updateMutation.mutateAsync({
|
||||
const result = await updateMutation.mutateAsync({
|
||||
pubkey: agent.pubkey,
|
||||
respondTo,
|
||||
respondToAllowlist:
|
||||
respondTo === "allowlist" ? respondToAllowlist : undefined,
|
||||
});
|
||||
showAgentProfileSyncWarning(result.agent.name, result.profileSyncError);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -206,33 +206,36 @@ export function MembersSidebarMemberCard({
|
||||
</div>
|
||||
)}
|
||||
{managedAgentRuntime || managedAgent ? (
|
||||
<Badge
|
||||
className="mt-1 normal-case tracking-normal"
|
||||
data-testid={`sidebar-managed-agent-status-${member.pubkey}`}
|
||||
variant={
|
||||
managedAgentRuntime
|
||||
? agentCommunityAvailability(managedAgentRuntime) === "Here"
|
||||
? "default"
|
||||
: "secondary"
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
className="normal-case tracking-normal"
|
||||
data-testid={`sidebar-managed-agent-status-${member.pubkey}`}
|
||||
variant={
|
||||
managedAgentRuntime
|
||||
? agentCommunityAvailability(managedAgentRuntime) === "Here"
|
||||
? "default"
|
||||
: "secondary"
|
||||
: managedAgent && isManagedAgentActive(managedAgent)
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{managedAgentRuntime
|
||||
? agentCommunityAvailability(managedAgentRuntime)
|
||||
: managedAgent && isManagedAgentActive(managedAgent)
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{managedAgentRuntime
|
||||
? agentCommunityAvailability(managedAgentRuntime)
|
||||
: managedAgent && isManagedAgentActive(managedAgent)
|
||||
? "Running"
|
||||
: "Stopped"}
|
||||
</Badge>
|
||||
) : null}
|
||||
{managedAgent ? (
|
||||
<span
|
||||
className="sr-only"
|
||||
data-testid={`sidebar-managed-agent-respond-to-${member.pubkey}`}
|
||||
>
|
||||
{formatRespondToLabel(managedAgent)}
|
||||
</span>
|
||||
? "Running"
|
||||
: "Stopped"}
|
||||
</Badge>
|
||||
{managedAgent ? (
|
||||
<Badge
|
||||
className="normal-case tracking-normal"
|
||||
data-testid={`sidebar-managed-agent-respond-to-${member.pubkey}`}
|
||||
variant="outline"
|
||||
>
|
||||
{formatRespondToLabel(managedAgent)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent";
|
||||
import {
|
||||
pickDirectProfileAgent,
|
||||
pickProfileAgent,
|
||||
} from "@/features/agents/lib/pickProfileAgent";
|
||||
import { useUserProfileQuery } from "@/features/profile/hooks";
|
||||
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
|
||||
import { useOwnedManagedAgentPersonaId } from "@/features/profile/lib/useOwnedManagedAgentPersonaId";
|
||||
@@ -11,6 +14,7 @@ export function useCanonicalManagedAgentProfile(input: {
|
||||
currentPubkey: string | undefined;
|
||||
managedAgents: readonly ManagedAgent[] | undefined;
|
||||
personaId: string | undefined;
|
||||
preferDirectManagedAgent?: boolean;
|
||||
preserveRequestedInstance?: boolean;
|
||||
pubkey: string | undefined;
|
||||
}) {
|
||||
@@ -18,6 +22,7 @@ export function useCanonicalManagedAgentProfile(input: {
|
||||
currentPubkey,
|
||||
managedAgents,
|
||||
personaId,
|
||||
preferDirectManagedAgent = false,
|
||||
preserveRequestedInstance = false,
|
||||
pubkey,
|
||||
} = input;
|
||||
@@ -48,13 +53,20 @@ export function useCanonicalManagedAgentProfile(input: {
|
||||
(agent) => agent.personaId === linkedPersonaId,
|
||||
);
|
||||
}, [directManagedAgent, linkedPersonaId, managedAgents]);
|
||||
const managedAgent = React.useMemo(
|
||||
() =>
|
||||
preserveRequestedInstance && directManagedAgent
|
||||
? directManagedAgent
|
||||
: (pickProfileAgent(personaInstances) ?? directManagedAgent),
|
||||
[directManagedAgent, personaInstances, preserveRequestedInstance],
|
||||
);
|
||||
const managedAgent = React.useMemo(() => {
|
||||
if (directManagedAgent) {
|
||||
if (preserveRequestedInstance) return directManagedAgent;
|
||||
if (preferDirectManagedAgent) {
|
||||
return pickDirectProfileAgent(directManagedAgent, personaInstances);
|
||||
}
|
||||
}
|
||||
return pickProfileAgent(personaInstances) ?? directManagedAgent;
|
||||
}, [
|
||||
directManagedAgent,
|
||||
personaInstances,
|
||||
preferDirectManagedAgent,
|
||||
preserveRequestedInstance,
|
||||
]);
|
||||
|
||||
return { linkedPersonaId, managedAgent, personaInstances };
|
||||
}
|
||||
|
||||
@@ -187,7 +187,6 @@ export function UserProfilePanel({
|
||||
requestedInstancePubkey &&
|
||||
normalizePubkey(pubkey) === normalizePubkey(requestedInstancePubkey),
|
||||
);
|
||||
|
||||
const personasQuery = usePersonasQuery();
|
||||
const managedAgentsQuery = useManagedAgentsQuery({ enabled: true });
|
||||
const { linkedPersonaId, managedAgent, personaInstances } =
|
||||
@@ -195,6 +194,7 @@ export function UserProfilePanel({
|
||||
currentPubkey,
|
||||
managedAgents: managedAgentsQuery.data,
|
||||
personaId: persona?.id,
|
||||
preferDirectManagedAgent: true,
|
||||
preserveRequestedInstance,
|
||||
pubkey,
|
||||
});
|
||||
@@ -398,15 +398,17 @@ export function UserProfilePanel({
|
||||
onClose,
|
||||
viewerIsOwner,
|
||||
});
|
||||
|
||||
const openResolvedPersonaEditor = React.useCallback(() => {
|
||||
if (!resolvedPersona) return false;
|
||||
setPersonaDialogState(
|
||||
editPersonaDialogState(resolvedPersona, managedAgent),
|
||||
);
|
||||
return true;
|
||||
}, [managedAgent, resolvedPersona]);
|
||||
const handleEditAgent = React.useCallback(() => {
|
||||
if (resolvedPersona) {
|
||||
setPersonaDialogState(editPersonaDialogState(resolvedPersona));
|
||||
return;
|
||||
}
|
||||
if (openResolvedPersonaEditor()) return;
|
||||
setEditAgentOpen(true);
|
||||
}, [resolvedPersona, setEditAgentOpen]);
|
||||
|
||||
}, [openResolvedPersonaEditor, setEditAgentOpen]);
|
||||
const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } =
|
||||
useProfileAgentDeletion({
|
||||
channels: channelsQuery.data,
|
||||
@@ -545,10 +547,7 @@ export function UserProfilePanel({
|
||||
],
|
||||
);
|
||||
|
||||
const handleEditPersona = React.useCallback(() => {
|
||||
if (!resolvedPersona) return;
|
||||
setPersonaDialogState(editPersonaDialogState(resolvedPersona));
|
||||
}, [resolvedPersona]);
|
||||
const handleEditPersona = openResolvedPersonaEditor;
|
||||
|
||||
const handleDuplicatePersona = React.useCallback(() => {
|
||||
if (!resolvedPersona) return;
|
||||
@@ -913,7 +912,7 @@ export function UserProfilePanel({
|
||||
? () => {
|
||||
setEditAgentOpen(false);
|
||||
setEditAgentFocus(undefined);
|
||||
setPersonaDialogState(editPersonaDialogState(resolvedPersona));
|
||||
openResolvedPersonaEditor();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ function persona(overrides = {}) {
|
||||
namePool: [],
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
envVars: { NEW_KEY: "2" },
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
@@ -93,6 +95,42 @@ test("personaManagedAgentUpdate syncs edited persona identity to linked agent",
|
||||
});
|
||||
});
|
||||
|
||||
test("personaManagedAgentUpdate syncs definition access to the linked agent", () => {
|
||||
assert.deepEqual(
|
||||
personaManagedAgentUpdate(
|
||||
agent({ respondTo: "anyone" }),
|
||||
persona({ respondTo: "owner-only" }),
|
||||
),
|
||||
{
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
name: "Fizz Prime",
|
||||
systemPrompt: "New prompt",
|
||||
model: "new-model",
|
||||
envVars: { NEW_KEY: "2" },
|
||||
respondTo: "owner-only",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
personaManagedAgentUpdate(
|
||||
agent({ respondTo: "anyone" }),
|
||||
persona({
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: ["a".repeat(64)],
|
||||
}),
|
||||
),
|
||||
{
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
name: "Fizz Prime",
|
||||
systemPrompt: "New prompt",
|
||||
model: "new-model",
|
||||
envVars: { NEW_KEY: "2" },
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: ["a".repeat(64)],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => {
|
||||
assert.equal(
|
||||
personaManagedAgentUpdate(agent({ personaId: "persona-2" }), persona()),
|
||||
|
||||
@@ -298,6 +298,22 @@ export function personaManagedAgentUpdate(
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Definition edits expose the access policy in the same dialog as identity
|
||||
// and runtime settings. Keep the exact linked instance in sync when the
|
||||
// definition carries an explicit policy; otherwise the dialog reopens with
|
||||
// the new value while the running agent and sidebar retain the old one.
|
||||
if (persona.respondTo != null && persona.respondTo !== agent.respondTo) {
|
||||
input.respondTo = persona.respondTo;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (
|
||||
persona.respondTo === "allowlist" &&
|
||||
!stringArrayEqual(persona.respondToAllowlist, agent.respondToAllowlist)
|
||||
) {
|
||||
input.respondToAllowlist = [...persona.respondToAllowlist];
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
const runtimeChanged =
|
||||
options.previousPersona !== undefined &&
|
||||
options.previousPersona.runtime !== persona.runtime;
|
||||
|
||||
@@ -102,6 +102,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
||||
if (!agentsByPubkey.has(agent.pubkey)) {
|
||||
agentsByPubkey.set(agent.pubkey, {
|
||||
pubkey: agent.pubkey,
|
||||
ownerPubkey: null,
|
||||
name: agent.name,
|
||||
agentType: agent.agentCommand,
|
||||
channels: [],
|
||||
|
||||
@@ -100,6 +100,7 @@ type RawSearchResponse = {
|
||||
|
||||
type RawRelayAgent = {
|
||||
pubkey: string;
|
||||
owner_pubkey?: string | null;
|
||||
name: string;
|
||||
agent_type: string;
|
||||
channels: string[];
|
||||
@@ -109,7 +110,6 @@ type RawRelayAgent = {
|
||||
respond_to?: RelayAgent["respondTo"];
|
||||
respond_to_allowlist?: string[];
|
||||
};
|
||||
|
||||
import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff";
|
||||
export type RawManagedAgent = {
|
||||
pubkey: string;
|
||||
@@ -652,10 +652,10 @@ export async function createAuthEvent(input: {
|
||||
const eventJson = await invokeTauri<string>("create_auth_event", input);
|
||||
return JSON.parse(eventJson) as RelayEvent;
|
||||
}
|
||||
|
||||
function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent {
|
||||
return {
|
||||
pubkey: agent.pubkey,
|
||||
ownerPubkey: agent.owner_pubkey ?? null,
|
||||
name: agent.name,
|
||||
agentType: agent.agent_type,
|
||||
channels: agent.channels,
|
||||
|
||||
@@ -266,9 +266,9 @@ export type RelayMember = {
|
||||
addedBy: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type RelayAgent = {
|
||||
pubkey: string;
|
||||
ownerPubkey: string | null;
|
||||
name: string;
|
||||
agentType: string;
|
||||
channels: string[];
|
||||
|
||||
@@ -46,28 +46,30 @@ test("open agent access explains the available access before save", async ({
|
||||
name: "Hack Day Helper",
|
||||
status: "running",
|
||||
channelNames: ["general"],
|
||||
respondTo: "owner-only",
|
||||
respondTo: "anyone",
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.getByTestId("channel-members-trigger").click();
|
||||
const accessBadge = page.getByTestId(
|
||||
`sidebar-managed-agent-respond-to-${agent.pubkey}`,
|
||||
);
|
||||
await expect(accessBadge).toBeVisible();
|
||||
await expect(accessBadge).toHaveText("Anyone");
|
||||
await openAgentAccessDialog(page, agent.pubkey);
|
||||
|
||||
const accessSelect = page.getByTestId("agent-respond-to-select");
|
||||
await expect(accessSelect).toHaveValue("owner-only");
|
||||
await expect(page.getByTestId("agent-access-warning")).toHaveCount(0);
|
||||
await expect(accessSelect).toHaveValue("anyone");
|
||||
const saveAccess = page.getByRole("button", { name: "Save access" });
|
||||
await expect(saveAccess).toBeVisible();
|
||||
|
||||
const commandsBeforeSave = await page.evaluate(
|
||||
() => window.__BUZZ_E2E_COMMAND_LOG__?.length ?? 0,
|
||||
);
|
||||
await accessSelect.selectOption("anyone");
|
||||
const warning = page.getByTestId("agent-access-warning");
|
||||
await expect(warning).toBeVisible();
|
||||
await expect(warning).toContainText(
|
||||
"Anyone can use this agent to access your computer, including files, accounts, and connected tools.",
|
||||
);
|
||||
await accessSelect.selectOption("owner-only");
|
||||
await expect(page.getByTestId("agent-access-warning")).toHaveCount(0);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page
|
||||
@@ -88,17 +90,19 @@ test("open agent access explains the available access before save", async ({
|
||||
(entry) =>
|
||||
entry.command === "update_managed_agent" &&
|
||||
(entry.payload as { input?: { respondTo?: string } })?.input
|
||||
?.respondTo === "anyone",
|
||||
?.respondTo === "owner-only",
|
||||
);
|
||||
}, commandsBeforeSave),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(accessBadge).toHaveText("Only me");
|
||||
|
||||
await openAgentAccessDialog(page, agent.pubkey);
|
||||
await expect(accessSelect).toHaveValue("anyone");
|
||||
await expect(accessSelect).toHaveValue("owner-only");
|
||||
// Selected people narrows the audience but not the access, so the warning
|
||||
// persists with its own audience phrase.
|
||||
await accessSelect.selectOption("allowlist");
|
||||
const warning = page.getByTestId("agent-access-warning");
|
||||
await expect(warning).toBeVisible();
|
||||
await expect(warning).toContainText(
|
||||
"Selected people can use this agent to access your computer, including files, accounts, and connected tools.",
|
||||
@@ -124,6 +128,134 @@ test("open agent access explains the available access before save", async ({
|
||||
await expect(warning).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("full agent editor tightens the exact sidebar agent instance", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = TEST_IDENTITIES.tyler;
|
||||
const preferredSiblingPubkey = "d".repeat(64);
|
||||
const personaId = "shared-sidebar-agent";
|
||||
await installMockBridge(page, {
|
||||
acpRuntimesCatalog: [
|
||||
{
|
||||
availability: "available",
|
||||
command: "goose",
|
||||
default_args: [],
|
||||
id: "goose",
|
||||
install_hint: "",
|
||||
label: "Goose",
|
||||
mcp_command: "",
|
||||
},
|
||||
],
|
||||
globalAgentConfig: {
|
||||
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test-key" },
|
||||
model: "claude-opus-4-5",
|
||||
preferred_runtime: "goose",
|
||||
provider: "anthropic",
|
||||
},
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: agent.pubkey,
|
||||
name: "Tyler Agent",
|
||||
personaId,
|
||||
status: "running",
|
||||
channelNames: ["general"],
|
||||
respondTo: "anyone",
|
||||
},
|
||||
{
|
||||
pubkey: preferredSiblingPubkey,
|
||||
name: "Preferred Sibling",
|
||||
personaId,
|
||||
status: "running",
|
||||
channelNames: [],
|
||||
respondTo: "anyone",
|
||||
},
|
||||
],
|
||||
personas: [
|
||||
{
|
||||
displayName: "Shared Sidebar Agent",
|
||||
id: personaId,
|
||||
isActive: true,
|
||||
respondTo: "anyone",
|
||||
runtime: "goose",
|
||||
systemPrompt: "Test exact instance editing.",
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.getByTestId("channel-members-trigger").click();
|
||||
const accessBadge = page.getByTestId(
|
||||
`sidebar-managed-agent-respond-to-${agent.pubkey}`,
|
||||
);
|
||||
await expect(accessBadge).toHaveText("Anyone");
|
||||
|
||||
await page.getByTestId(`sidebar-member-${agent.pubkey}`).click();
|
||||
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
||||
await page.getByTestId("user-profile-edit-agent").click();
|
||||
const dialog = page.getByRole("dialog", { name: "Edit agent" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Advanced" }).click();
|
||||
await choosePersonaAccess(page, "Only me (default)");
|
||||
await dialog.getByRole("tab", { name: "Customize for this agent" }).click();
|
||||
const saveChanges = dialog.getByRole("button", { name: "Save changes" });
|
||||
await expect(saveChanges).toBeEnabled();
|
||||
await saveChanges.click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
const updateCommand = await page.evaluate(
|
||||
(pubkey) =>
|
||||
window.__BUZZ_E2E_COMMAND_LOG__?.findLast(
|
||||
(entry) =>
|
||||
entry.command === "update_managed_agent" &&
|
||||
(entry.payload as { input?: { pubkey?: string } })?.input?.pubkey ===
|
||||
pubkey,
|
||||
),
|
||||
agent.pubkey,
|
||||
);
|
||||
expect(updateCommand?.payload).toMatchObject({
|
||||
input: { pubkey: agent.pubkey, respondTo: "owner-only" },
|
||||
});
|
||||
expect(updateCommand?.payload).not.toMatchObject({
|
||||
input: { pubkey: preferredSiblingPubkey },
|
||||
});
|
||||
|
||||
await page.getByTestId("channel-members-trigger").click();
|
||||
await expect(accessBadge).toHaveText("Only me");
|
||||
|
||||
// The definition still says "anyone" after the instance-only save above.
|
||||
// Reopening the same linked agent for an unrelated prompt edit must seed
|
||||
// access from the exact instance, or the submit silently widens it again.
|
||||
await page.getByTestId(`sidebar-member-${agent.pubkey}`).click();
|
||||
await page.getByTestId("user-profile-edit-agent").click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Advanced" }).click();
|
||||
await expect(page.locator("#agent-respond-to")).toHaveText(
|
||||
"Only me (default)",
|
||||
);
|
||||
await page
|
||||
.locator("#persona-system-prompt")
|
||||
.fill("Test unrelated prompt editing after tightening access.");
|
||||
await dialog.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
const unrelatedEditCommand = await page.evaluate(
|
||||
(pubkey) =>
|
||||
window.__BUZZ_E2E_COMMAND_LOG__?.findLast(
|
||||
(entry) =>
|
||||
entry.command === "update_managed_agent" &&
|
||||
(entry.payload as { input?: { pubkey?: string } })?.input?.pubkey ===
|
||||
pubkey,
|
||||
),
|
||||
agent.pubkey,
|
||||
);
|
||||
expect(unrelatedEditCommand?.payload).toMatchObject({
|
||||
input: { pubkey: agent.pubkey },
|
||||
});
|
||||
expect(unrelatedEditCommand?.payload).not.toMatchObject({
|
||||
input: { respondTo: "anyone" },
|
||||
});
|
||||
});
|
||||
|
||||
test("a provider-backed agent's warning names the server, not this computer", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user