Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6971822
fix(agents): sync private managed config via relay
wesbillman Aug 6, 2026
a9b648b
fix(agents): preserve managed overlay lifecycle
wesbillman Aug 6, 2026
c80c4c1
fix(agents): persist relay config across restarts and stop stale repu…
Aug 6, 2026
aa39d72
fix(agents): stop boot reconcile republishing stale config over a new…
Aug 6, 2026
6f486e8
fix(agents): write self-authored config back to the private-config ov…
Aug 6, 2026
d945edf
Merge origin/main into carl/relay-primary-agent-config
Aug 7, 2026
692fdaf
fix(desktop): export/card resolves through the 30179 overlay; snapsho…
Aug 7, 2026
2719088
test(desktop): guard that every export resolver folds the 30179 overlay
Aug 7, 2026
637a796
refactor(desktop): satisfy the file-size ratchet — extract the overla…
Aug 7, 2026
11eee16
Merge origin/main into carl/relay-primary-agent-config
Aug 9, 2026
67f4895
Merge origin/main into carl/relay-primary-agent-config
Aug 11, 2026
01eec9b
Merge remote-tracking branch 'origin/main' into carl/relay-primary-ag…
Aug 13, 2026
2b02892
Merge remote-tracking branch 'origin/main' into carl/relay-primary-ag…
wesbillman Aug 16, 2026
a6d4b30
Merge remote-tracking branch 'origin/main' into carl/relay-primary-ag…
Aug 20, 2026
6c7bb84
fix(desktop): harden private-config tombstones and equal-second reten…
Aug 20, 2026
e4db7b3
chore(desktop): satisfy file-size ratchet for review-fix growth
Aug 20, 2026
9ed90c1
fix(desktop): launch restore spawns relay-resolved config, not raw di…
Aug 20, 2026
ab620e9
Merge remote-tracking branch 'origin/main' into carl/relay-primary-ag…
Aug 20, 2026
bd856e9
Keep boot reconcile unit tests off the live OS keyring
Aug 20, 2026
8f75e90
fix(desktop): resolve relay overlay at pair-start and provider-deploy…
Aug 21, 2026
6f10b38
Merge origin/main into carl/relay-primary-agent-config
Aug 21, 2026
25753b6
Merge remote branch carl/relay-primary-agent-config
Aug 21, 2026
5741bad
Merge origin/main (26f4c3ed3) into carl/relay-primary-agent-config
Aug 24, 2026
03a3228
fix(desktop): settle provider deploy on the relay-resolved record; re…
Aug 24, 2026
63f8621
Merge origin/main into carl/relay-primary-agent-config
Aug 25, 2026
f7c2477
Merge origin/main into carl/relay-primary-agent-config
Aug 25, 2026
66995b1
fix(managed-agents): boot reconcile survives pin-less records
Aug 25, 2026
a3f68c9
test(managed-agents): cover the boot-reconcile continue-on-error arm
Aug 25, 2026
de33fd8
Merge origin/main into carl/relay-primary-agent-config
Aug 26, 2026
d133aed
Merge origin/main (0dbd036f5) into carl/relay-primary-agent-config
Sep 2, 2026
c6770b6
fix(desktop): checkpoint relay-primary agent recovery repairs
Sep 3, 2026
6c1be33
Merge origin/main into relay-primary agent repair checkpoint
Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
710 changes: 273 additions & 437 deletions crates/buzz-core/src/private_managed_agent.rs

Large diffs are not rendered by default.

31 changes: 16 additions & 15 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@ use buzz_core::kind::{
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE,
KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE,
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER,
RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE,
KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION,
KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED,
KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED,
KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM,
KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER,
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
RELAY_ADMIN_SET_WORKSPACE_PROFILE,
};
use buzz_core::tenant::TenantContext;
use buzz_core::verification::verify_event;
Expand Down Expand Up @@ -263,7 +264,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT
| KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE => {
| KIND_PRIVATE_MANAGED_AGENT | KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE => {
Ok(Scope::UsersWrite)
}
// NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner).
Expand Down Expand Up @@ -476,6 +477,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
// (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them.
| KIND_TEAM
| KIND_MANAGED_AGENT
| KIND_PRIVATE_MANAGED_AGENT
| KIND_TEAM_CATALOG
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
Expand Down Expand Up @@ -3338,15 +3340,14 @@ mod tests {
}

#[test]
fn private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists() {
assert!(
required_scope_for_kind(
buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT,
&make_dummy_event(),
)
.is_err(),
"kind 30179 must not enter generic EVENT ingest before privacy and aggregate CAS deploy"
fn private_managed_agent_kind_is_owner_scoped_global_user_data() {
let event = make_dummy_event();
assert_eq!(
required_scope_for_kind(KIND_PRIVATE_MANAGED_AGENT, &event),
Ok(Scope::UsersWrite)
);
assert!(is_global_only_kind(KIND_PRIVATE_MANAGED_AGENT));
assert!(!requires_h_channel_scope(KIND_PRIVATE_MANAGED_AGENT));
}

#[test]
Expand Down
20 changes: 10 additions & 10 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,28 +35,27 @@ pub struct AppState {
/// Workspace-provided relay URL override. Set by `apply_workspace` on app
/// init and takes priority over env vars and compile-time defaults.
pub relay_url_override: Mutex<Option<String>>,
/// Set during backend setup when managed agents are eligible for launch
/// restore. `apply_workspace` consumes it after installing the workspace
/// relay and identity, so agents never start against the fallback relay.
/// Set during backend setup when managed agents are eligible for launch 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.
/// Whether desktop may repair managed-agent kind:0 profiles from local records.
/// Disabled by the experiment so agent profile updates survive start/restore.
pub managed_agent_profile_reconcile_enabled: AtomicBool,
/// Shared shutdown signal checked by launch-time agent restoration.
/// Shared shutdown signal for launch-time agent restoration.
pub shutdown_started: AtomicBool,
/// Serializes every managed-runtime transition that changes the protected
/// PID set: spawn/register, adoption, stop, shutdown, and sweep snapshots.
/// Never perform network I/O while holding this lock.
pub managed_agent_runtime_transition: Mutex<()>,
pub managed_agents_store_lock: Mutex<()>,
pub(crate) private_managed_agent_overlay:
Mutex<crate::managed_agents::private_config_overlay::PrivateConfigOverlay>,
pub channel_templates_store_lock: Mutex<()>,
pub managed_agent_processes: Mutex<HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>>,
pub huddle_state: Mutex<HuddleState>,
pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState,
/// Tauri app handle — stored after setup so huddle commands can emit
/// `huddle-state-changed` events without needing the handle threaded
/// through every call site.
/// Tauri handle for emitting huddle events.
///
/// Set once during `setup()` in `lib.rs`; never cleared.
pub app_handle: Mutex<Option<AppHandle>>,
Expand Down Expand Up @@ -213,6 +212,7 @@ pub fn build_app_state() -> AppState {
managed_agent_runtime_transition: Mutex::new(()),
identity_mutation: Mutex::new(()),
managed_agents_store_lock: Mutex::new(()),
private_managed_agent_overlay: Mutex::new(Default::default()),
channel_templates_store_lock: Mutex::new(()),
managed_agent_processes: Mutex::new(HashMap::new()),
session_config_cache: Mutex::new(HashMap::new()),
Expand Down
67 changes: 28 additions & 39 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,49 +70,12 @@ pub(super) fn retain_managed_agent_pending(
/// `pending_sync = 1`. The `d_tag` is the agent's pubkey. Best-effort: a
/// failure is logged and swallowed so a retention hiccup never blocks the
/// disk-authoritative delete.
pub(super) fn tombstone_managed_agent_pending(
pub(crate) fn tombstone_managed_agent_pending(
app: &AppHandle,
state: &AppState,
agent_pubkey: &str,
) {
use crate::managed_agents::{
agent_events::build_agent_delete,
retention::{
delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag,
RetainedEvent,
},
};
use buzz_core_pkg::kind::KIND_MANAGED_AGENT;
use nostr::JsonUtil;

const KIND_DELETE: u32 = 5;

let result = (|| -> Result<(), String> {
let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
let owner_pubkey = scope.owner_keys.public_key().to_hex();
let event = build_agent_delete(agent_pubkey, &owner_pubkey)?
.sign_with_keys(&scope.owner_keys)
.map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?;
let conn = open_retention_db(&scope.db_path)?;
delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?;
retain_event(
&conn,
&RetainedEvent {
kind: KIND_DELETE,
pubkey: owner_pubkey,
// Key by the target coordinate so cross-kind d-tag tombstones
// occupy distinct rows (F2c).
d_tag: tombstone_retention_d_tag(KIND_MANAGED_AGENT, agent_pubkey),
content: event.content.to_string(),
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: true,
},
)
})();
if let Err(e) = result {
eprintln!("buzz-desktop: agent-tombstone: {e}");
}
crate::managed_agents::agent_events::tombstone_managed_agent_pending(app, state, agent_pubkey);
}

/// Build and sign the NIP-IA `kind:9035` archive request enqueued when an
Expand Down Expand Up @@ -543,6 +506,11 @@ pub async fn list_managed_agents(app: AppHandle) -> Result<Vec<ManagedAgentSumma
for pubkey in &exited_pubkeys {
state.clear_agent_session_caches(pubkey);
}
let records = state
.private_managed_agent_overlay
.lock()
.map_err(|error| error.to_string())?
.resolved_records(&records);

let personas = load_personas(&app).unwrap_or_default();
// One disk read for the whole list — build_managed_agent_summary takes
Expand Down Expand Up @@ -1071,6 +1039,27 @@ pub async fn start_managed_agent(
// Snapshot the workspace owner pubkey for the legacy auth_tag fallback.
// Read outside the records lock to keep lock ordering simple.
let owner_hex = workspace_owner_hex(&state)?;
let local_records = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
load_managed_agents(&app)?
};
if state
.private_managed_agent_overlay
.lock()
.map_err(|error| error.to_string())?
.contains(&pubkey)
{
return crate::managed_agents::private_config_overlay::start_relay_only_agent(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — Do not divert every overlaid disk record around its existing start lifecycle. Presence in this in-memory overlay is not equivalent to being relay-only: resolved_record can start from an existing disk-backed record and then apply the patch. This branch therefore bypasses start_local_agent_with_preflight (including effective-config/relay-mesh validation, persona handling, saving start metadata, retained publication, and profile reconciliation), and it bypasses provider deployment entirely because the helper rejects any resolved provider backend. Merely receiving a valid private event can thus make an ordinary local/provider agent follow a materially weaker or unusable path. Distinguish true relay-only records from disk-backed records and route the latter through the normal resolved local/provider pipeline; add a regression proving an overlaid disk record retains those lifecycle guarantees.

&app,
&state,
&pubkey,
&owner_hex,
&local_records,
);
}
enum StartTarget {
Local,
Provider {
Expand Down
13 changes: 13 additions & 0 deletions desktop/src-tauri/src/commands/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,14 @@ fn commit_imported_identity(

let storage = persist(&keys)?;

// Serialize the identity scope transition with inbound private-config
// handling, which holds this lock from owner resolution through overlay
// insertion. Otherwise an old-owner patch could land after this clear.
let _managed_agents_store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;

// Update in-memory keys BEFORE clearing recovery flags. The Release
// stores below pair with Acquire loads in get_identity: a reader
// observing false is guaranteed to see the updated keys.
Expand All @@ -424,6 +432,11 @@ fn commit_imported_identity(
*active_keys = keys;
state.set_identity_storage(storage);
}
state
.private_managed_agent_overlay
.lock()
.map_err(|e| e.to_string())?
.clear();

// Clear both recovery flags — an import is valid in either lost or
// keyring-locked state and resolves both. In the locked case the
Expand Down
103 changes: 98 additions & 5 deletions desktop/src-tauri/src/commands/personas/inbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ fn reconcile_inbound_persona_event_blocking(
save_managed_agents, save_teams,
team_events::team_content_from_event,
};
use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM};
use buzz_core_pkg::kind::{
KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_PRIVATE_MANAGED_AGENT, KIND_TEAM,
};
use nostr::JsonUtil;

let state = app.state::<AppState>();
Expand All @@ -96,10 +98,17 @@ fn reconcile_inbound_persona_event_blocking(
return reconcile_inbound_tombstone(&event, &arrival_relay_url, &app, &state);
}

if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) {
if !matches!(
kind,
KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT
) {
return Ok(());
}

if kind == KIND_PRIVATE_MANAGED_AGENT {
return reconcile_inbound_private_managed_agent(&event, &arrival_relay_url, &app, &state);
}

// The d-tag identifies the record within its kind. Persona derives it from
// the parsed record (`persona_d_tag`); team/agent carry it as the event's
// d-tag directly. The persona is parsed once here and reused in the apply
Expand Down Expand Up @@ -182,6 +191,80 @@ fn reconcile_inbound_persona_event_blocking(
Ok(())
}

fn apply_inbound_private_managed_agent_event(
event: &nostr::Event,
owner_keys: &nostr::Keys,
conn: &rusqlite::Connection,
overlay: &mut crate::managed_agents::private_config_overlay::PrivateConfigOverlay,
) -> Result<crate::managed_agents::retention::InboundOutcome, String> {
use crate::managed_agents::{
private_config_overlay::PrivateConfigPatch,
retention::{retain_inbound_event, InboundOutcome, RetainedEvent},
};
use buzz_core_pkg::{kind::KIND_PRIVATE_MANAGED_AGENT, private_managed_agent};
use nostr::JsonUtil;

// The codec verifies signature/owner, decrypts, validates the nsec binding,
// and rejects malformed portable config before any local state changes.
let (_, payload) = private_managed_agent::validate_and_decrypt(event, owner_keys)
.map_err(|error| format!("invalid private managed-agent event: {error}"))?;
let d_tag = payload.agent_pubkey.clone();
// Constructing the Desktop patch validates backend-specific fields without
// mutating the live overlay or retained head.
let patch = PrivateConfigPatch::from_payload(payload)?;
let outcome = retain_inbound_event(
conn,
&RetainedEvent {
kind: KIND_PRIVATE_MANAGED_AGENT,
pubkey: event.pubkey.to_hex(),
d_tag,
content: event.content.clone(),
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: false,
},
)?;
if outcome == InboundOutcome::Applied {
overlay.insert_patch(patch);
}
Ok(outcome)
}

fn reconcile_inbound_private_managed_agent(
event: &nostr::Event,
arrival_relay_url: &str,
app: &AppHandle,
state: &AppState,
) -> Result<(), String> {
use crate::managed_agents::retention::{open_retention_db, InboundOutcome};

let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let Some(scope) =
crate::managed_agents::retention::arrival_retention_scope(app, state, arrival_relay_url)?
else {
return Ok(());
};

let mut overlay = state
.private_managed_agent_overlay
.lock()
.map_err(|error| error.to_string())?;
let conn = open_retention_db(&scope.db_path)?;
let outcome =
apply_inbound_private_managed_agent_event(event, &scope.owner_keys, &conn, &mut overlay)?;
if outcome == InboundOutcome::Skipped {
return Ok(());
}

drop(overlay);
try_regenerate_nest(app);
let _ = app.emit("agents-data-changed", ());
Ok(())
}

/// Parse an inbound wire event and enforce the signature gate. Everything
/// downstream trusts `event.pubkey` (ownership routing, tombstone scoping,
/// behavioral-quad application), so a forged pubkey must die here — the
Expand Down Expand Up @@ -244,13 +327,18 @@ fn reconcile_inbound_tombstone(
},
save_managed_agents, save_teams,
};
use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM};
use buzz_core_pkg::kind::{
KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_PRIVATE_MANAGED_AGENT, KIND_TEAM,
};
use nostr::JsonUtil;

let Some((target_kind, target_d_tag)) = parse_deletion_coordinate(event) else {
return Ok(()); // no routable coordinate — nothing to delete
};
if !matches!(target_kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) {
if !matches!(
target_kind,
KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT
) {
return Ok(()); // deletion for a kind we don't track locally
}

Expand Down Expand Up @@ -299,7 +387,12 @@ fn reconcile_inbound_tombstone(
teams.retain(|record| record.id != target_d_tag);
save_teams(app, &teams)?;
}
KIND_MANAGED_AGENT => {
KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT => {
state
.private_managed_agent_overlay
.lock()
.map_err(|error| error.to_string())?
.remove(&target_d_tag);
let mut agents = load_managed_agents(app)?;
agents.retain(|record| record.pubkey != target_d_tag);
save_managed_agents(app, &agents)?;
Expand Down
Loading
Loading