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:
Wes
2026-08-17 09:51:00 -07:00
committed by GitHub
co-authored by diegorumo Carl Brain
parent 1b7e5ac1be
commit f716eef437
73 changed files with 3214 additions and 935 deletions
+3 -3
View File
@@ -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()]);
}
}
+6 -213
View File
@@ -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, &current_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, &current_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(&current.respond_to_allowlist);
restored.updated_at.clone_from(&current.updated_at);
}
copy_runtime_state(current, &mut restored);
if runtime_changed {
restored.updated_at.clone_from(&current.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());
+15 -80
View File
@@ -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,
+2
View File
@@ -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,
+4
View File
@@ -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 {
+10 -431
View File
@@ -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");
}
+6 -2
View File
@@ -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
+4 -8
View File
@@ -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("Couldnt 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: [],
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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[];
+143 -11
View File
@@ -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,
}) => {