fix(agents): sync private managed config via relay

Keep encrypted runnable configuration in an owner-scoped relay event while
preserving local records as migration state. Validate inbound payloads before
retention, expose fresh-device records through an ephemeral scoped overlay,
and retain public/private heads and tombstones atomically.

Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-08-05 20:51:19 -06:00
parent 16cc3de6d6
commit 6971822eee
16 changed files with 1371 additions and 526 deletions

View File

@@ -1,8 +1,8 @@
//! NIP-PMA private managed-agent wire codec.
//!
//! This module defines and validates the inert wire format only. Relays must
//! not accept [`KIND_PRIVATE_MANAGED_AGENT`](crate::kind::KIND_PRIVATE_MANAGED_AGENT)
//! until the dedicated privacy and aggregate-CAS transactions are deployed.
//! This module defines and validates the owner-authored encrypted wire format.
//! Relays treat it as global owner data; Desktop performs all decryption and
//! device-specific runtime validation.
use std::collections::{BTreeMap, HashSet};
use std::fmt;
@@ -18,7 +18,7 @@ use serde_json::Value;
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::kind::{KIND_MANAGED_AGENT, KIND_PERSONA, KIND_PRIVATE_MANAGED_AGENT};
use crate::kind::KIND_PRIVATE_MANAGED_AGENT;
/// Wire-format discriminator for decrypted private managed-agent payloads.
pub const FORMAT: &str = "buzz-private-managed-agent";
@@ -63,65 +63,6 @@ pub enum Error {
Sign,
}
/// Authoritative lifecycle state repeated in the outer tags and ciphertext.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum State {
/// Runnable aggregate.
Active,
/// Anti-resurrection tombstone.
Deleted,
}
impl State {
fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Deleted => "deleted",
}
}
}
/// Versioned signed-event recovery material for a bound public projection.
///
/// Retaining the complete signed event makes reconstruction unambiguous: its
/// signature, ID, author, kind, coordinate, and exact content bytes can all be
/// checked without trusting replaceable-event history.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectionRecoveryV1 {
/// Recovery schema version. Version 1 stores one complete signed event.
pub version: u32,
/// Exact signed public projection event.
pub signed_event: Event,
}
/// Complete definition projection binding and recovery material.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DefinitionBinding {
/// CAS-managed definition revision pinned by this aggregate.
pub revision: u64,
/// Exact signed kind:30175 event ID.
pub event_id: String,
/// Lowercase SHA-256 of the exact projection content bytes.
pub content_sha256: String,
/// Versioned signed event sufficient to reproduce the projection.
pub recovery: ProjectionRecoveryV1,
}
/// Complete kind:30177 projection binding and recovery material.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstanceBinding {
/// Exact signed kind:30177 event ID.
pub event_id: String,
/// Lowercase SHA-256 of the exact projection content bytes.
pub content_sha256: String,
/// Versioned signed event sufficient to reproduce the projection.
pub recovery: ProjectionRecoveryV1,
}
/// Secret agent identity material. It never appears in public projections.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -144,14 +85,46 @@ impl fmt::Debug for PrivateIdentity {
}
/// Portable private runnable configuration.
///
/// Forward-compatible: unknown JSON members authored by a newer Desktop are
/// preserved verbatim in [`PrivateConfig::extra`] rather than rejected, so an
/// older writer round-tripping this config cannot silently drop them. Known
/// members are still strictly typed; unknown members can never override a
/// known field (serde routes a matching key to the typed field first).
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PrivateConfig {
/// Explicit kind:30175 coordinate, when definition-backed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub definition_coordinate: Option<String>,
/// Intended relay endpoint; validated again on each device before use.
pub relay_url: String,
/// Unique agent handle (`ManagedAgentRecord.name`). Required for fresh-device
/// reconstruction. Non-empty.
pub name: String,
/// Stable definition/persona slug (`ManagedAgentRecord.persona_id`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona_id: Option<String>,
/// Preferred ACP runtime id, e.g. `"goose"`/`"claude"`. `None` = inherit.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
/// Desired LLM model id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// LLM inference provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// System prompt.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
/// Turn parallelism. `None` = the Desktop default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parallelism: Option<u32>,
/// Inbound author gate mode as the NIP-AP wire string
/// (`"owner-only"`/`"allowlist"`/`"anyone"`). Wire string, not the Desktop
/// `RespondTo` enum, so unknown future modes round-trip verbatim. `None` =
/// the Desktop default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub respond_to: Option<String>,
/// Allowlist used when `respond_to == "allowlist"`; normalized lowercase hex.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub respond_to_allowlist: Vec<String>,
/// Explicit harness override; never launched without local validation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_command_override: Option<String>,
@@ -181,6 +154,14 @@ pub struct PrivateConfig {
/// Versioned provider/definition relay-mesh marker.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relay_mesh: Option<Value>,
/// Unknown JSON members preserved verbatim for forward compatibility.
///
/// A newer Desktop may author config keys this version does not model; they
/// round-trip here untouched so an older writer never drops them. Never
/// contains a key that collides with a known field above (serde binds known
/// keys first). Core semantics must never depend on this map.
#[serde(flatten)]
pub extra: serde_json::Map<String, Value>,
}
impl fmt::Debug for PrivateConfig {
@@ -192,23 +173,13 @@ impl fmt::Debug for PrivateConfig {
}
}
/// Fields present only when [`Payload::state`] is [`State::Active`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActivePayload {
/// Exact definition projection binding.
pub definition: DefinitionBinding,
/// Exact public instance projection binding.
pub instance_projection: InstanceBinding,
/// Secret identity material.
pub identity: PrivateIdentity,
/// Private portable/device-validated configuration.
pub config: PrivateConfig,
}
/// Decrypted private managed-agent payload.
///
/// Forward-compatible at the top level: unknown JSON members authored by a
/// newer Desktop round-trip verbatim in [`Payload::extra`] (see [`PrivateConfig`]
/// for the same guarantee on config). Known members remain strictly typed and
/// validated; an unknown member can never override a known field.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Payload {
/// Always [`FORMAT`].
pub format: String,
@@ -218,24 +189,27 @@ pub struct Payload {
pub agent_pubkey: String,
/// Owner pubkey and signed event author.
pub owner_pubkey: String,
/// Monotonic CAS generation.
/// Advisory monotonic generation (validated shape, never CAS-enforced).
pub generation: u64,
/// Exact predecessor event ID; absent only for generation one.
/// Advisory predecessor event ID; absent exactly at generation one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_event_id: Option<String>,
/// Lifecycle state, repeated in the outer `state` tag.
pub state: State,
/// RFC3339 bookkeeping timestamp; never used for conflict resolution.
pub updated_at: String,
/// Required for active records and forbidden for tombstones.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<ActivePayload>,
/// Required for tombstones and forbidden for active records.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<String>,
/// Secret identity material.
pub identity: PrivateIdentity,
/// Private portable/device-validated configuration.
pub config: PrivateConfig,
/// Forward-compatible namespaced data. Core semantics must never depend on it.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extensions: BTreeMap<String, Value>,
/// Unknown top-level JSON members preserved verbatim for forward
/// compatibility. A newer Desktop may author payload keys this version does
/// not model; they round-trip here untouched so an older writer never drops
/// them. Never contains a key that collides with a known field above (serde
/// binds known keys first). Core semantics must never depend on this map.
#[serde(flatten)]
pub extra: serde_json::Map<String, Value>,
}
/// Validated public metadata from a private managed-agent event.
@@ -245,12 +219,10 @@ pub struct Envelope {
pub agent_pubkey: PublicKey,
/// Owner pubkey from the signed event author.
pub owner_pubkey: PublicKey,
/// CAS generation from `g`.
/// Advisory generation from `g` (validated shape, never CAS-enforced).
pub generation: u64,
/// CAS predecessor from `prev`.
/// Advisory predecessor from `prev`.
pub previous_event_id: Option<EventId>,
/// Lifecycle state from `state`.
pub state: State,
}
/// Compute the lowercase SHA-256 binding for exact projection content bytes.
@@ -280,7 +252,6 @@ pub fn validate_envelope(event: &Event, expected_owner: &PublicKey) -> Result<En
let mut d = None;
let mut g = None;
let mut prev = None;
let mut state = None;
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.len() != 2 {
@@ -292,7 +263,6 @@ pub fn validate_envelope(event: &Event, expected_owner: &PublicKey) -> Result<En
"d" => &mut d,
"g" => &mut g,
"prev" => &mut prev,
"state" => &mut state,
name => return Err(Error::InvalidEnvelope(format!("unexpected tag: {name}"))),
};
if slot.replace(parts[1].clone()).is_some() {
@@ -322,18 +292,11 @@ pub fn validate_envelope(event: &Event, expected_owner: &PublicKey) -> Result<En
"prev must be absent exactly at generation 1".into(),
));
}
let state = match state.as_deref() {
Some("active") => State::Active,
Some("deleted") => State::Deleted,
Some(_) => return Err(Error::InvalidEnvelope("invalid state tag".into())),
None => return Err(Error::InvalidEnvelope("missing state tag".into())),
};
Ok(Envelope {
agent_pubkey,
owner_pubkey,
generation,
previous_event_id,
state,
})
}
@@ -362,7 +325,6 @@ pub fn build_event(owner_keys: &Keys, payload: &Payload, created_at: u64) -> Res
let mut tags = vec![
parse_tag(["d", payload.agent_pubkey.as_str()])?,
parse_tag(["g", payload.generation.to_string().as_str()])?,
parse_tag(["state", payload.state.as_str()])?,
];
if let Some(previous) = payload.previous_event_id.as_deref() {
tags.push(parse_tag(["prev", previous])?);
@@ -396,7 +358,6 @@ pub fn validate_and_decrypt(
if payload.agent_pubkey != envelope.agent_pubkey.to_hex()
|| payload.owner_pubkey != envelope.owner_pubkey.to_hex()
|| payload.generation != envelope.generation
|| payload.state != envelope.state
|| payload.previous_event_id.as_deref()
!= envelope
.previous_event_id
@@ -420,7 +381,7 @@ pub fn validate_payload(payload: &Payload) -> Result<(), Error> {
}
let agent = parse_canonical_pubkey("agent_pubkey", &payload.agent_pubkey)
.map_err(|e| Error::InvalidPayload(e.to_string()))?;
parse_canonical_pubkey("owner_pubkey", &payload.owner_pubkey)
let owner = parse_canonical_pubkey("owner_pubkey", &payload.owner_pubkey)
.map_err(|e| Error::InvalidPayload(e.to_string()))?;
validate_generation_and_prev(payload.generation, payload.previous_event_id.as_deref())?;
parse_rfc3339("updated_at", &payload.updated_at)?;
@@ -432,77 +393,36 @@ pub fn validate_payload(payload: &Payload) -> Result<(), Error> {
}
validate_value_size("extension", value)?;
}
match payload.state {
State::Active => {
if payload.deleted_at.is_some() {
return Err(Error::InvalidPayload(
"active payload must not contain deleted_at".into(),
));
}
let active = payload.active.as_ref().ok_or_else(|| {
Error::InvalidPayload("active payload missing active body".into())
})?;
validate_active(active, &agent, &payload.owner_pubkey)?;
}
State::Deleted => {
if payload.active.is_some() {
return Err(Error::InvalidPayload(
"deleted payload must not contain active body".into(),
));
}
parse_rfc3339(
"deleted_at",
payload.deleted_at.as_deref().ok_or_else(|| {
Error::InvalidPayload("deleted payload missing deleted_at".into())
})?,
)?;
}
}
validate_identity_and_config(&payload.identity, &payload.config, &agent, &owner)?;
Ok(())
}
fn validate_active(
active: &ActivePayload,
/// Validate the secret identity and portable config of a payload.
///
/// The nsec must derive the payload's `agent_pubkey` (the `d` coordinate), and
/// the config's bounds must hold. This is the nsec→coordinate binding gate.
fn validate_identity_and_config(
identity: &PrivateIdentity,
config: &PrivateConfig,
agent: &PublicKey,
owner_pubkey: &str,
owner: &PublicKey,
) -> Result<(), Error> {
if active.definition.revision == 0 || active.definition.revision > MAX_SAFE_GENERATION {
return Err(Error::InvalidPayload("invalid definition revision".into()));
}
let definition_d =
parse_definition_coordinate(active.config.definition_coordinate.as_deref(), owner_pubkey)?;
validate_binding(
"definition",
KIND_PERSONA,
owner_pubkey,
Some(&definition_d),
&active.definition.event_id,
&active.definition.content_sha256,
&active.definition.recovery,
)?;
validate_binding(
"instance_projection",
KIND_MANAGED_AGENT,
owner_pubkey,
Some(&agent.to_hex()),
&active.instance_projection.event_id,
&active.instance_projection.content_sha256,
&active.instance_projection.recovery,
)?;
let agent_keys = Keys::parse(active.identity.private_key_nsec.trim())
let agent_keys = Keys::parse(identity.private_key_nsec.trim())
.map_err(|_| Error::InvalidPayload("invalid agent nsec".into()))?;
if agent_keys.public_key() != *agent {
return Err(Error::InvalidPayload(
"agent nsec does not derive agent_pubkey".into(),
));
}
if let Some(auth_tag) = &active.identity.auth_tag {
validate_auth_tag(auth_tag, owner_pubkey, agent)?;
if let Some(auth_tag) = &identity.auth_tag {
validate_auth_tag(auth_tag, &owner.to_hex(), agent)?;
}
let config = &active.config;
if config.relay_url.is_empty() || config.relay_url.len() > 4096 {
return Err(Error::InvalidPayload("invalid relay_url length".into()));
}
if config.name.is_empty() || config.name.len() > 4096 {
return Err(Error::InvalidPayload("invalid name length".into()));
}
if config.agent_args.len() > MAX_AGENT_ARGS
|| config
.agent_args
@@ -566,82 +486,6 @@ fn validate_auth_tag(auth_tag: &str, expected_owner: &str, agent: &PublicKey) ->
.map_err(|_| Error::InvalidPayload("invalid auth_tag signature".into()))
}
fn parse_definition_coordinate(
coordinate: Option<&str>,
owner_pubkey: &str,
) -> Result<String, Error> {
let coordinate = coordinate.ok_or_else(|| {
Error::InvalidPayload("active payload missing definition_coordinate".into())
})?;
let mut parts = coordinate.splitn(3, ':');
let kind = parts.next();
let owner = parts.next();
let d = parts.next();
if kind != Some("30175") || owner != Some(owner_pubkey) || d.is_none_or(str::is_empty) {
return Err(Error::InvalidPayload(
"definition_coordinate must be 30175:<owner>:<non-empty d>".into(),
));
}
Ok(d.unwrap().to_owned())
}
fn validate_binding(
label: &str,
expected_kind: u32,
owner_pubkey: &str,
expected_d: Option<&str>,
event_id: &str,
hash: &str,
recovery: &ProjectionRecoveryV1,
) -> Result<(), Error> {
parse_event_id(label, event_id).map_err(|e| Error::InvalidPayload(e.to_string()))?;
parse_lower_hex_32(&format!("{label}.content_sha256"), hash)
.map_err(|e| Error::InvalidPayload(e.to_string()))?;
if recovery.version != 1 {
return Err(Error::InvalidPayload(format!(
"unsupported {label} recovery version"
)));
}
let event = &recovery.signed_event;
if !event.verify_id() || !event.verify_signature() {
return Err(Error::InvalidPayload(format!(
"invalid {label} recovery event"
)));
}
if event.id.to_hex() != event_id
|| event.kind.as_u16() as u32 != expected_kind
|| event.pubkey.to_hex() != owner_pubkey
|| content_sha256(event.content.as_bytes()) != hash
{
return Err(Error::InvalidPayload(format!(
"{label} recovery does not match binding"
)));
}
let d_tags: Vec<_> = event
.tags
.iter()
.filter_map(|tag| {
let parts = tag.as_slice();
(parts.first().map(String::as_str) == Some("d")).then_some(parts)
})
.collect();
if d_tags.len() != 1 || d_tags[0].len() != 2 || d_tags[0][1].is_empty() {
return Err(Error::InvalidPayload(format!(
"{label} recovery must have exactly one non-empty d tag"
)));
}
if expected_d.is_some_and(|expected| d_tags[0][1] != expected) {
return Err(Error::InvalidPayload(format!(
"{label} recovery has wrong coordinate"
)));
}
validate_value_size(
label,
&serde_json::to_value(recovery)
.map_err(|_| Error::InvalidPayload(format!("invalid {label}")))?,
)
}
fn validate_generation_and_prev(generation: u64, previous: Option<&str>) -> Result<(), Error> {
if generation == 0 || generation > MAX_SAFE_GENERATION {
return Err(Error::InvalidPayload(
@@ -814,21 +658,9 @@ mod tests {
.to_string()
}
/// Minimal valid payload: the nsec derives `agent_pubkey`, generation 1,
/// required config fields present, no unknown members.
fn payload(owner: &Keys, agent: &Keys) -> Payload {
let definition_event = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition")
.tags(vec![Tag::parse(["d", "test-agent"]).unwrap()])
.custom_created_at(nostr::Timestamp::from(1_785_780_000))
.sign_with_keys(owner)
.unwrap();
let instance_event = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), "instance")
.tags(vec![Tag::parse([
"d",
agent.public_key().to_hex().as_str(),
])
.unwrap()])
.custom_created_at(nostr::Timestamp::from(1_785_780_000))
.sign_with_keys(owner)
.unwrap();
Payload {
format: FORMAT.into(),
version: VERSION,
@@ -836,50 +668,36 @@ mod tests {
owner_pubkey: owner.public_key().to_hex(),
generation: 1,
previous_event_id: None,
state: State::Active,
updated_at: "2026-08-03T18:00:00Z".into(),
active: Some(ActivePayload {
definition: DefinitionBinding {
revision: 1,
event_id: definition_event.id.to_hex(),
content_sha256: content_sha256(definition_event.content.as_bytes()),
recovery: ProjectionRecoveryV1 {
version: 1,
signed_event: definition_event,
},
},
instance_projection: InstanceBinding {
event_id: instance_event.id.to_hex(),
content_sha256: content_sha256(instance_event.content.as_bytes()),
recovery: ProjectionRecoveryV1 {
version: 1,
signed_event: instance_event,
},
},
identity: PrivateIdentity {
private_key_nsec: agent.secret_key().to_bech32().unwrap(),
auth_tag: None,
},
config: PrivateConfig {
definition_coordinate: Some(format!(
"30175:{}:test-agent",
owner.public_key().to_hex()
)),
relay_url: "wss://relay.example".into(),
agent_command_override: None,
agent_args: vec![],
idle_timeout_seconds: Some(300),
max_turn_duration_seconds: None,
env_vars: BTreeMap::from([("SECRET".into(), "not-public".into())]),
backend: serde_json::json!({"type": "local"}),
backend_agent_id: None,
team_id: None,
persona_name_in_team: None,
relay_mesh: None,
},
}),
deleted_at: None,
identity: PrivateIdentity {
private_key_nsec: agent.secret_key().to_bech32().unwrap(),
auth_tag: None,
},
config: PrivateConfig {
relay_url: "wss://relay.example".into(),
name: "aphid".into(),
persona_id: Some("aphid-def".into()),
runtime: Some("goose".into()),
model: None,
provider: None,
system_prompt: Some("be terse".into()),
parallelism: Some(2),
respond_to: Some("owner-only".into()),
respond_to_allowlist: vec![],
agent_command_override: None,
agent_args: vec![],
idle_timeout_seconds: Some(300),
max_turn_duration_seconds: None,
env_vars: BTreeMap::from([("SECRET".into(), "not-public".into())]),
backend: serde_json::json!({"type": "local"}),
backend_agent_id: None,
team_id: None,
persona_name_in_team: None,
relay_mesh: None,
extra: serde_json::Map::new(),
},
extensions: BTreeMap::new(),
extra: serde_json::Map::new(),
}
}
@@ -894,7 +712,7 @@ mod tests {
assert_eq!(envelope.agent_pubkey, agent.public_key());
assert_eq!(envelope.owner_pubkey, owner.public_key());
assert_eq!(envelope.generation, 1);
assert_eq!(envelope.state, State::Active);
assert_eq!(envelope.previous_event_id, None);
}
#[test]
@@ -902,16 +720,9 @@ mod tests {
let owner = Keys::generate();
let agent = Keys::generate();
let mut candidate = payload(&owner, &agent);
let private_key_nsec = candidate
.active
.as_ref()
.unwrap()
.identity
.private_key_nsec
.clone();
let active = candidate.active.as_mut().unwrap();
active.identity.auth_tag = Some("secret-auth-tag".into());
active.config.backend = serde_json::json!({"token": "secret-backend-token"});
let private_key_nsec = candidate.identity.private_key_nsec.clone();
candidate.identity.auth_tag = Some("secret-auth-tag".into());
candidate.config.backend = serde_json::json!({"token": "secret-backend-token"});
let debug = format!("{candidate:?}");
assert!(debug.contains("<redacted>"));
@@ -921,6 +732,7 @@ mod tests {
assert!(!debug.contains("secret-backend-token"));
}
// (1) privacy / wrong-owner + tamper fail closed.
#[test]
fn wrong_owner_and_tampering_fail_closed() {
let owner = Keys::generate();
@@ -940,62 +752,12 @@ mod tests {
));
}
#[test]
fn duplicate_and_unknown_json_fields_are_rejected() {
let duplicate = br#"{"format":"a","format":"b"}"#;
assert!(matches!(
parse_strict_json(duplicate),
Err(Error::InvalidPayload(message)) if message.contains("duplicate key")
));
let owner = Keys::generate();
let agent = Keys::generate();
let mut value = serde_json::to_value(payload(&owner, &agent)).unwrap();
value
.as_object_mut()
.unwrap()
.insert("surprise".into(), Value::Bool(true));
let err = serde_json::from_value::<Payload>(value).unwrap_err();
assert!(err.to_string().contains("unknown field"));
}
#[test]
fn auth_tag_must_be_unconditional_and_bound_to_owner_and_agent() {
let owner = Keys::generate();
let agent = Keys::generate();
let mut candidate = payload(&owner, &agent);
candidate.active.as_mut().unwrap().identity.auth_tag = Some(auth_tag(&owner, &agent));
validate_payload(&candidate).unwrap();
candidate.active.as_mut().unwrap().identity.auth_tag =
Some(auth_tag(&Keys::generate(), &agent));
assert!(validate_payload(&candidate).is_err());
candidate.active.as_mut().unwrap().identity.auth_tag =
Some(auth_tag(&owner, &Keys::generate()));
assert!(validate_payload(&candidate).is_err());
let mut self_attested = payload(&owner, &owner);
self_attested.active.as_mut().unwrap().identity.auth_tag = Some(auth_tag(&owner, &owner));
assert!(matches!(
validate_payload(&self_attested),
Err(Error::InvalidPayload(message)) if message.contains("distinct agent key")
));
let valid = auth_tag(&owner, &agent);
let mut parts: Vec<String> = serde_json::from_str(&valid).unwrap();
parts[2] = "kind=9".into();
candidate.active.as_mut().unwrap().identity.auth_tag =
Some(serde_json::to_string(&parts).unwrap());
assert!(validate_payload(&candidate).is_err());
}
// (2) nsec -> pubkey binding: the identity nsec must derive agent_pubkey (d).
#[test]
fn active_identity_must_derive_coordinate() {
let owner = Keys::generate();
let mut candidate = payload(&owner, &Keys::generate());
candidate.active.as_mut().unwrap().identity.private_key_nsec =
Keys::generate().secret_key().to_bech32().unwrap();
candidate.identity.private_key_nsec = Keys::generate().secret_key().to_bech32().unwrap();
assert!(matches!(
validate_payload(&candidate),
Err(Error::InvalidPayload(message)) if message.contains("does not derive")
@@ -1003,23 +765,160 @@ mod tests {
}
#[test]
fn tombstone_requires_successor_shape() {
fn valid_owner_attestation_passes_and_binds_agent() {
let owner = Keys::generate();
let agent = Keys::generate();
let mut deleted = payload(&owner, &agent);
deleted.generation = 2;
deleted.previous_event_id = Some("33".repeat(32));
deleted.state = State::Deleted;
deleted.active = None;
deleted.deleted_at = Some("2026-08-03T18:01:00Z".into());
validate_payload(&deleted).unwrap();
let mut candidate = payload(&owner, &agent);
// The owner signs an unconditional attestation over the AGENT key.
candidate.identity.auth_tag = Some(auth_tag(&owner, &agent));
assert!(validate_payload(&candidate).is_ok());
deleted.previous_event_id = None;
assert!(validate_payload(&deleted).is_err());
// Round-trips end-to-end with the attestation intact.
let event = build_event(&owner, &candidate, 1_785_780_000).unwrap();
let (_envelope, decoded) = validate_and_decrypt(&event, &owner).unwrap();
assert_eq!(decoded, candidate);
}
#[test]
fn outer_tag_grammar_rejects_duplicates_and_noncanonical_generation() {
fn auth_tag_from_wrong_attestor_is_rejected() {
let owner = Keys::generate();
let agent = Keys::generate();
let mut candidate = payload(&owner, &agent);
// A stranger (not the owner) signs the attestation: parts[1] is not the
// owner pubkey, so the attestation is rejected.
let stranger = Keys::generate();
candidate.identity.auth_tag = Some(auth_tag(&stranger, &agent));
assert!(matches!(
validate_payload(&candidate),
Err(Error::InvalidPayload(message)) if message.contains("auth_tag")
));
}
#[test]
fn auth_tag_signature_must_verify() {
let owner = Keys::generate();
let agent = Keys::generate();
let mut candidate = payload(&owner, &agent);
// Correct owner in parts[1], but the signature is over a DIFFERENT agent
// key, so schnorr verification against this agent's preimage fails.
let other_agent = Keys::generate();
let preimage = format!("nostr:agent-auth:{}:", other_agent.public_key().to_hex());
let digest = Sha256::digest(preimage.as_bytes());
let signature = owner.sign_schnorr(&Message::from_digest(digest.into()));
candidate.identity.auth_tag = Some(
serde_json::json!([
"auth",
owner.public_key().to_hex(),
"",
signature.to_string()
])
.to_string(),
);
assert!(matches!(
validate_payload(&candidate),
Err(Error::InvalidPayload(message)) if message.contains("signature")
));
}
// (3) unknown-field round-trip: unknown top-level + config members survive
// verbatim through serialize/deserialize and land in the `extra` maps.
#[test]
fn unknown_members_round_trip_verbatim() {
let owner = Keys::generate();
let agent = Keys::generate();
let mut candidate = payload(&owner, &agent);
candidate
.extra
.insert("future_top".into(), serde_json::json!({"nested": [1, 2]}));
candidate
.config
.extra
.insert("future_cfg".into(), Value::String("keep-me".into()));
let event = build_event(&owner, &candidate, 1_785_780_000).unwrap();
let (_envelope, decoded) = validate_and_decrypt(&event, &owner).unwrap();
assert_eq!(decoded, candidate);
assert_eq!(
decoded.extra.get("future_top"),
Some(&serde_json::json!({"nested": [1, 2]}))
);
assert_eq!(
decoded.config.extra.get("future_cfg"),
Some(&Value::String("keep-me".into()))
);
}
// (3b) an unknown member can never override a known field: serde binds the
// typed field first, so a colliding key is impossible to smuggle into `extra`.
#[test]
fn unknown_member_cannot_override_known_field() {
let owner = Keys::generate();
let agent = Keys::generate();
let mut value = serde_json::to_value(payload(&owner, &agent)).unwrap();
// Inject a duplicate-looking known key into the config object; serde
// routes it to the typed `name`, NOT to `extra`.
value["config"]["name"] = Value::String("renamed".into());
let decoded: Payload = serde_json::from_value(value).unwrap();
assert_eq!(decoded.config.name, "renamed");
assert!(!decoded.config.extra.contains_key("name"));
}
// (4) required / null semantics: a missing required known field is rejected;
// duplicate JSON keys are rejected by the strict parser.
#[test]
fn required_fields_and_duplicate_keys() {
let owner = Keys::generate();
let agent = Keys::generate();
// Missing required `config.name`.
let mut value = serde_json::to_value(payload(&owner, &agent)).unwrap();
value["config"].as_object_mut().unwrap().remove("name");
assert!(serde_json::from_value::<Payload>(value).is_err());
// Duplicate top-level key rejected pre-deserialization.
let duplicate = br#"{"format":"a","format":"b"}"#;
assert!(matches!(
parse_strict_json(duplicate),
Err(Error::InvalidPayload(message)) if message.contains("duplicate key")
));
// Empty required `name` fails semantic validation.
let mut empty_name = payload(&owner, &agent);
empty_name.config.name = String::new();
assert!(matches!(
validate_payload(&empty_name),
Err(Error::InvalidPayload(message)) if message.contains("invalid name length")
));
}
// gen/prev are a3 advisory metadata: shape is validated (gen1 XOR prev,
// canonical decimal, outer/inner equality) but ordering is never enforced.
#[test]
fn generation_prev_shape_is_validated_metadata() {
let owner = Keys::generate();
let agent = Keys::generate();
// Higher generation with a well-formed prev round-trips fine — no head
// consult, no staleness rejection.
let mut successor = payload(&owner, &agent);
successor.generation = 7;
successor.previous_event_id = Some("33".repeat(32));
let event = build_event(&owner, &successor, 1_785_780_001).unwrap();
let (envelope, decoded) = validate_and_decrypt(&event, &owner).unwrap();
assert_eq!(envelope.generation, 7);
assert_eq!(decoded.generation, 7);
// prev present at generation 1 violates the shape rule.
let mut bad = payload(&owner, &agent);
bad.previous_event_id = Some("33".repeat(32));
assert!(matches!(
validate_payload(&bad),
Err(Error::InvalidPayload(message)) if message.contains("absent exactly at generation 1")
));
}
#[test]
fn outer_tag_grammar_rejects_noncanonical_generation_and_stray_tags() {
let owner = Keys::generate();
let agent = Keys::generate();
let body = payload(&owner, &agent);
@@ -1030,102 +929,39 @@ mod tests {
Version::V2,
)
.unwrap();
let event = EventBuilder::new(Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), ciphertext)
// Non-canonical generation "01".
let event = EventBuilder::new(
Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16),
ciphertext.clone(),
)
.tags(vec![
Tag::parse(["d", agent.public_key().to_hex().as_str()]).unwrap(),
Tag::parse(["g", "01"]).unwrap(),
])
.sign_with_keys(&owner)
.unwrap();
assert!(matches!(
validate_envelope(&event, &owner.public_key()),
Err(Error::InvalidEnvelope(message)) if message.contains("canonical decimal")
));
// A stray lifecycle `state` tag is now unexpected (lifecycle removed).
let stray = EventBuilder::new(Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), ciphertext)
.tags(vec![
Tag::parse(["d", agent.public_key().to_hex().as_str()]).unwrap(),
Tag::parse(["g", "01"]).unwrap(),
Tag::parse(["g", "1"]).unwrap(),
Tag::parse(["state", "active"]).unwrap(),
])
.sign_with_keys(&owner)
.unwrap();
assert!(matches!(
validate_envelope(&event, &owner.public_key()),
Err(Error::InvalidEnvelope(message)) if message.contains("canonical decimal")
validate_envelope(&stray, &owner.public_key()),
Err(Error::InvalidEnvelope(message)) if message.contains("unexpected tag")
));
}
#[test]
fn projection_recovery_must_match_binding_and_coordinate() {
let owner = Keys::generate();
let agent = Keys::generate();
let mut candidate = payload(&owner, &agent);
let active = candidate.active.as_mut().unwrap();
active.instance_projection.content_sha256 = content_sha256(b"wrong");
assert!(matches!(
validate_payload(&candidate),
Err(Error::InvalidPayload(message)) if message.contains("does not match binding")
));
let mut candidate = payload(&owner, &agent);
candidate
.active
.as_mut()
.unwrap()
.config
.definition_coordinate =
Some(format!("30175:{}:wrong-slug", owner.public_key().to_hex()));
assert!(matches!(
validate_payload(&candidate),
Err(Error::InvalidPayload(message)) if message.contains("wrong coordinate")
));
let mut candidate = payload(&owner, &agent);
candidate
.active
.as_mut()
.unwrap()
.definition
.recovery
.version = 2;
assert!(matches!(
validate_payload(&candidate),
Err(Error::InvalidPayload(message)) if message.contains("unsupported definition recovery version")
));
let mut candidate = payload(&owner, &agent);
candidate
.active
.as_mut()
.unwrap()
.definition
.recovery
.signed_event
.content
.push('!');
assert!(matches!(
validate_payload(&candidate),
Err(Error::InvalidPayload(message)) if message.contains("invalid definition recovery event")
));
let mut candidate = payload(&owner, &agent);
let wrong_kind = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), "definition")
.tags(vec![Tag::parse(["d", "test-agent"]).unwrap()])
.sign_with_keys(&owner)
.unwrap();
let definition = &mut candidate.active.as_mut().unwrap().definition;
definition.event_id = wrong_kind.id.to_hex();
definition.content_sha256 = content_sha256(wrong_kind.content.as_bytes());
definition.recovery.signed_event = wrong_kind;
assert!(matches!(
validate_payload(&candidate),
Err(Error::InvalidPayload(message)) if message.contains("does not match binding")
));
let mut candidate = payload(&owner, &agent);
let missing_d = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition")
.sign_with_keys(&owner)
.unwrap();
let definition = &mut candidate.active.as_mut().unwrap().definition;
definition.event_id = missing_d.id.to_hex();
definition.content_sha256 = content_sha256(missing_d.content.as_bytes());
definition.recovery.signed_event = missing_d;
assert!(matches!(
validate_payload(&candidate),
Err(Error::InvalidPayload(message)) if message.contains("exactly one non-empty d tag")
));
}
#[test]
fn projection_hash_fixture_is_stable() {
fn hash_fixture_is_stable() {
assert_eq!(
content_sha256(b"buzz-private-managed-agent-v1"),
"c3ca1603249c95343fc1766ba58d075d6bdf0e57b375bef38738729b2022cc80"

View File

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

View File

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

View File

@@ -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
@@ -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
@@ -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(
&app,
&state,
&pubkey,
&owner_hex,
&local_records,
);
}
enum StartTarget {
Local,
Provider {

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
//! Extracted from the parent module to keep it under the file-size cap.
use super::*;
use nostr::{JsonUtil, ToBech32};
use std::collections::BTreeMap;
const UUID: &str = "11111111-2222-3333-4444-555555555555";
@@ -155,6 +156,114 @@ fn no_local_match_inserts_inbound_reusing_d_tag_as_id() {
const AGENT_PUBKEY: &str = "agentpubkeyhex0000000000000000000000000000000000000000000000000000";
fn private_agent_payload(
owner_keys: &nostr::Keys,
agent_keys: &nostr::Keys,
name: &str,
parallelism: u32,
) -> buzz_core_pkg::private_managed_agent::Payload {
use buzz_core_pkg::private_managed_agent::{
Payload, PrivateConfig, PrivateIdentity, FORMAT, VERSION,
};
Payload {
format: FORMAT.into(),
version: VERSION,
agent_pubkey: agent_keys.public_key().to_hex(),
owner_pubkey: owner_keys.public_key().to_hex(),
generation: 1,
previous_event_id: None,
updated_at: "2026-08-06T00:00:00Z".into(),
identity: PrivateIdentity {
private_key_nsec: agent_keys.secret_key().to_bech32().unwrap(),
auth_tag: None,
},
config: PrivateConfig {
relay_url: "wss://relay.example".into(),
name: name.into(),
persona_id: None,
runtime: Some("goose".into()),
model: None,
provider: None,
system_prompt: Some("relay prompt".into()),
parallelism: Some(parallelism),
respond_to: None,
respond_to_allowlist: vec![],
agent_command_override: None,
agent_args: vec![],
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
env_vars: BTreeMap::new(),
backend: serde_json::json!({"type":"local"}),
backend_agent_id: None,
team_id: None,
persona_name_in_team: None,
relay_mesh: None,
extra: serde_json::Map::new(),
},
extensions: BTreeMap::new(),
extra: serde_json::Map::new(),
}
}
#[test]
fn private_agent_inbound_rejects_before_retain_and_stale_event_preserves_overlay() {
use crate::managed_agents::{
private_config_overlay::PrivateConfigOverlay,
retention::{get_retained_event, open_retention_db, InboundOutcome},
};
use buzz_core_pkg::{kind::KIND_PRIVATE_MANAGED_AGENT, private_managed_agent};
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
let owner_keys = nostr::Keys::generate();
let agent_keys = nostr::Keys::generate();
let pubkey = agent_keys.public_key().to_hex();
let mut overlay = PrivateConfigOverlay::default();
let valid = private_agent_payload(&owner_keys, &agent_keys, "new", 4);
let newer_event = private_managed_agent::build_event(&owner_keys, &valid, 20).unwrap();
assert_eq!(
apply_inbound_private_managed_agent_event(&newer_event, &owner_keys, &conn, &mut overlay,)
.unwrap(),
InboundOutcome::Applied
);
assert_eq!(overlay.resolved_records(&[])[0].name, "new");
let mut malformed = private_agent_payload(&owner_keys, &agent_keys, "malformed", 4);
malformed.generation = 2;
malformed.previous_event_id = Some(newer_event.id.to_hex());
malformed.config.backend = serde_json::json!({"type":"provider"});
let malformed_event = private_managed_agent::build_event(&owner_keys, &malformed, 30).unwrap();
assert!(apply_inbound_private_managed_agent_event(
&malformed_event,
&owner_keys,
&conn,
&mut overlay,
)
.is_err());
assert_eq!(overlay.resolved_records(&[])[0].name, "new");
let retained = get_retained_event(
&conn,
KIND_PRIVATE_MANAGED_AGENT,
&owner_keys.public_key().to_hex(),
&pubkey,
)
.unwrap()
.unwrap();
assert_eq!(retained.raw_event, newer_event.as_json());
let stale = private_agent_payload(&owner_keys, &agent_keys, "stale", 2);
let stale_event = private_managed_agent::build_event(&owner_keys, &stale, 10).unwrap();
assert_eq!(
apply_inbound_private_managed_agent_event(&stale_event, &owner_keys, &conn, &mut overlay,)
.unwrap(),
InboundOutcome::Skipped
);
assert_eq!(overlay.resolved_records(&[])[0].name, "new");
}
/// A local managed agent carrying every device-local secret that an inbound
/// event must NEVER be able to overwrite.
fn local_agent() -> ManagedAgentRecord {

View File

@@ -164,6 +164,18 @@ pub async fn apply_workspace(
};
// ── Apply all state changes (nothing below can fail) ──────────────────
// Serialize the scope transition with inbound private-config handling.
// Inbound holds this lock from scope resolution through overlay insert,
// so a patch decrypted for the old workspace cannot land after this clear.
let _managed_agents_store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
state
.private_managed_agent_overlay
.lock()
.map_err(|e| e.to_string())?
.clear();
{
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
*override_guard = Some(relay_url);
@@ -176,6 +188,7 @@ pub async fn apply_workspace(
let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?;
*keys_guard = keys;
}
drop(_managed_agents_store_guard);
// Keep the backend-side reconcile guard aligned with the frontend
// experiment before launch-time restore can spawn any agents. Missing

View File

@@ -145,8 +145,91 @@ pub fn managed_agent_content_from_event(
/// event-id deletion path, leaving the parameterized-replaceable coordinate
/// live. The coordinate delete removes the agent for every client and across
/// reboots.
pub(crate) fn tombstone_managed_agent_pending(
app: &tauri::AppHandle,
state: &crate::app_state::AppState,
agent_pubkey: &str,
) {
use crate::managed_agents::retention::{
delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag,
RetainedEvent,
};
use buzz_core_pkg::kind::{KIND_MANAGED_AGENT, KIND_PRIVATE_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 public_delete = 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 private_delete = build_private_agent_delete(agent_pubkey, &owner_pubkey)?
.sign_with_keys(&scope.owner_keys)
.map_err(|e| format!("failed to sign private managed-agent tombstone: {e}"))?;
let conn = open_retention_db(&scope.db_path)?;
let transaction = conn
.unchecked_transaction()
.map_err(|error| format!("failed to begin agent deletion transaction: {error}"))?;
delete_retained_event(
&transaction,
KIND_MANAGED_AGENT,
&owner_pubkey,
agent_pubkey,
)?;
delete_retained_event(
&transaction,
KIND_PRIVATE_MANAGED_AGENT,
&owner_pubkey,
agent_pubkey,
)?;
for (target_kind, event) in [
(KIND_MANAGED_AGENT, public_delete),
(KIND_PRIVATE_MANAGED_AGENT, private_delete),
] {
retain_event(
&transaction,
&RetainedEvent {
kind: KIND_DELETE,
pubkey: owner_pubkey.clone(),
d_tag: tombstone_retention_d_tag(target_kind, agent_pubkey),
content: event.content.to_string(),
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: true,
},
)?;
}
transaction
.commit()
.map_err(|error| format!("failed to commit agent deletion transaction: {error}"))
})();
if let Err(e) = result {
eprintln!("buzz-desktop: agent-tombstone: {e}");
}
}
pub fn build_agent_delete(d_tag: &str, owner_pubkey_hex: &str) -> Result<EventBuilder, String> {
let coord = format!("{KIND_MANAGED_AGENT}:{owner_pubkey_hex}:{d_tag}");
build_agent_delete_for_kind(KIND_MANAGED_AGENT, d_tag, owner_pubkey_hex)
}
pub fn build_private_agent_delete(
d_tag: &str,
owner_pubkey_hex: &str,
) -> Result<EventBuilder, String> {
build_agent_delete_for_kind(
buzz_core_pkg::kind::KIND_PRIVATE_MANAGED_AGENT,
d_tag,
owner_pubkey_hex,
)
}
fn build_agent_delete_for_kind(
target_kind: u32,
d_tag: &str,
owner_pubkey_hex: &str,
) -> Result<EventBuilder, String> {
let coord = format!("{target_kind}:{owner_pubkey_hex}:{d_tag}");
let tag = Tag::parse(["a", coord.as_str()]).map_err(|e| format!("invalid a-tag: {e}"))?;
Ok(EventBuilder::new(Kind::Custom(5), "").tags(vec![tag]))
}

View File

@@ -22,6 +22,7 @@ pub(crate) mod parallelism;
mod persona_avatars;
pub(crate) mod persona_events;
mod personas;
pub(crate) mod private_config_overlay;
#[cfg(windows)]
mod process_lifecycle;
pub(crate) mod readiness;

View File

@@ -0,0 +1,378 @@
use std::collections::{BTreeMap, HashMap};
use buzz_core_pkg::private_managed_agent::Payload;
use super::{
build_managed_agent_summary, load_personas, start_managed_agent_process,
validate_respond_to_allowlist, validate_user_env_keys, BackendKind, ManagedAgentRecord,
ManagedAgentSummary, RelayMeshConfig, RespondTo, DEFAULT_ACP_COMMAND,
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
};
#[derive(Clone)]
pub(crate) struct PrivateConfigPatch {
pubkey: String,
name: String,
private_key_nsec: String,
auth_tag: Option<String>,
relay_url: String,
persona_id: Option<String>,
runtime: Option<String>,
model: Option<String>,
provider: Option<String>,
system_prompt: Option<String>,
parallelism: u32,
respond_to: RespondTo,
respond_to_allowlist: Vec<String>,
agent_command_override: Option<String>,
agent_args: Vec<String>,
idle_timeout_seconds: Option<u64>,
max_turn_duration_seconds: Option<u64>,
env_vars: BTreeMap<String, String>,
backend: BackendKind,
backend_agent_id: Option<String>,
team_id: Option<String>,
persona_name_in_team: Option<String>,
relay_mesh: Option<RelayMeshConfig>,
updated_at: String,
}
impl PrivateConfigPatch {
/// Convert a payload that has already passed the codec's
/// `validate_and_decrypt` gate. Callers must not feed unvalidated wire data.
pub(crate) fn from_payload(payload: Payload) -> Result<Self, String> {
let config = payload.config;
let backend = serde_json::from_value(config.backend)
.map_err(|error| format!("invalid private managed-agent backend: {error}"))?;
let relay_mesh = config
.relay_mesh
.map(serde_json::from_value)
.transpose()
.map_err(|error| format!("invalid private managed-agent relay_mesh: {error}"))?;
let respond_to = config
.respond_to
.as_deref()
.map(RespondTo::parse_wire)
.transpose()?
.unwrap_or_default();
let respond_to_allowlist = validate_respond_to_allowlist(&config.respond_to_allowlist)?;
if respond_to == RespondTo::Allowlist && respond_to_allowlist.is_empty() {
return Err("private managed-agent allowlist mode requires at least one pubkey".into());
}
validate_user_env_keys(&config.env_vars)?;
let parallelism = config.parallelism.unwrap_or(DEFAULT_AGENT_PARALLELISM);
if !(1..=32).contains(&parallelism) {
return Err("private managed-agent parallelism must be between 1 and 32".into());
}
Ok(Self {
pubkey: payload.agent_pubkey,
name: config.name,
private_key_nsec: payload.identity.private_key_nsec,
auth_tag: payload.identity.auth_tag,
relay_url: config.relay_url,
persona_id: config.persona_id,
runtime: config.runtime,
model: config.model,
provider: config.provider,
system_prompt: config.system_prompt,
parallelism,
respond_to,
respond_to_allowlist,
agent_command_override: config.agent_command_override,
agent_args: config.agent_args,
idle_timeout_seconds: config.idle_timeout_seconds,
max_turn_duration_seconds: config.max_turn_duration_seconds,
env_vars: config.env_vars,
backend,
backend_agent_id: config.backend_agent_id,
team_id: config.team_id,
persona_name_in_team: config.persona_name_in_team,
relay_mesh,
updated_at: payload.updated_at,
})
}
fn apply(&self, record: &mut ManagedAgentRecord) {
record.pubkey.clone_from(&self.pubkey);
record.name.clone_from(&self.name);
record.private_key_nsec.clone_from(&self.private_key_nsec);
record.auth_tag.clone_from(&self.auth_tag);
record.relay_url.clone_from(&self.relay_url);
record.persona_id.clone_from(&self.persona_id);
record.runtime.clone_from(&self.runtime);
record.model.clone_from(&self.model);
record.provider.clone_from(&self.provider);
record.system_prompt.clone_from(&self.system_prompt);
record.parallelism = self.parallelism;
record.respond_to = self.respond_to;
record
.respond_to_allowlist
.clone_from(&self.respond_to_allowlist);
record
.agent_command_override
.clone_from(&self.agent_command_override);
record.agent_args.clone_from(&self.agent_args);
record.idle_timeout_seconds = self.idle_timeout_seconds;
record.max_turn_duration_seconds = self.max_turn_duration_seconds;
record.env_vars.clone_from(&self.env_vars);
record.backend.clone_from(&self.backend);
record.backend_agent_id.clone_from(&self.backend_agent_id);
record.team_id.clone_from(&self.team_id);
record
.persona_name_in_team
.clone_from(&self.persona_name_in_team);
record.relay_mesh.clone_from(&self.relay_mesh);
record.updated_at.clone_from(&self.updated_at);
}
fn fresh_record(&self) -> ManagedAgentRecord {
let mut record = ManagedAgentRecord {
pubkey: String::new(),
name: String::new(),
persona_id: None,
team_id: None,
private_key_nsec: String::new(),
auth_tag: None,
relay_url: String::new(),
avatar_url: None,
acp_command: DEFAULT_ACP_COMMAND.into(),
agent_command: String::new(),
agent_command_override: None,
agent_args: vec![],
mcp_command: String::new(),
turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
parallelism: DEFAULT_AGENT_PARALLELISM,
system_prompt: None,
model: None,
provider: None,
persona_source_version: None,
env_vars: BTreeMap::new(),
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: BackendKind::Local,
backend_agent_id: None,
provider_binary_path: None,
persona_team_dir: None,
persona_name_in_team: None,
created_at: self.updated_at.clone(),
updated_at: self.updated_at.clone(),
last_started_at: None,
last_stopped_at: None,
last_exit_code: None,
last_error: None,
last_error_code: None,
respond_to: RespondTo::default(),
respond_to_allowlist: vec![],
display_name: None,
slug: None,
runtime: None,
name_pool: vec![],
is_builtin: false,
is_active: true,
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
};
self.apply(&mut record);
record
}
}
#[derive(Default)]
pub(crate) struct PrivateConfigOverlay(HashMap<String, PrivateConfigPatch>);
impl PrivateConfigOverlay {
#[cfg(test)]
pub(crate) fn insert(&mut self, payload: Payload) -> Result<(), String> {
let patch = PrivateConfigPatch::from_payload(payload)?;
self.0.insert(patch.pubkey.clone(), patch);
Ok(())
}
pub(crate) fn insert_patch(&mut self, patch: PrivateConfigPatch) {
self.0.insert(patch.pubkey.clone(), patch);
}
pub(crate) fn clear(&mut self) {
self.0.clear();
}
pub(crate) fn remove(&mut self, pubkey: &str) {
self.0.remove(pubkey);
}
pub(crate) fn contains(&self, pubkey: &str) -> bool {
self.0.contains_key(pubkey)
}
pub(crate) fn resolved_record(
&self,
pubkey: &str,
local: &[ManagedAgentRecord],
) -> Option<ManagedAgentRecord> {
let patch = self.0.get(pubkey)?;
let mut record = local
.iter()
.find(|record| record.pubkey == pubkey)
.cloned()
.unwrap_or_else(|| patch.fresh_record());
patch.apply(&mut record);
Some(record)
}
pub(crate) fn resolved_records(&self, local: &[ManagedAgentRecord]) -> Vec<ManagedAgentRecord> {
let mut resolved = local.to_vec();
for record in &mut resolved {
if let Some(patch) = self.0.get(&record.pubkey) {
patch.apply(record);
}
}
let mut relay_only: Vec<_> = self
.0
.values()
.filter(|patch| !local.iter().any(|record| record.pubkey == patch.pubkey))
.map(PrivateConfigPatch::fresh_record)
.collect();
relay_only.sort_by(|left, right| left.pubkey.cmp(&right.pubkey));
resolved.extend(relay_only);
resolved
}
}
pub(crate) fn start_relay_only_agent(
app: &tauri::AppHandle,
state: &crate::app_state::AppState,
pubkey: &str,
owner_hex: &str,
local_records: &[ManagedAgentRecord],
) -> Result<ManagedAgentSummary, String> {
let mut record = state
.private_managed_agent_overlay
.lock()
.map_err(|error| error.to_string())?
.resolved_record(pubkey, local_records)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
if local_records.iter().all(|local| local.pubkey != pubkey) {
record.persona_id = None;
}
if record.backend != BackendKind::Local {
return Err("relay-only provider agents cannot be started on this device".into());
}
let personas = load_personas(app).unwrap_or_default();
super::try_record_agent_command(&record, &personas)
.map_err(|error| super::user_facing_harness_error(&error))?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
start_managed_agent_process(app, &mut record, &mut runtimes, Some(owner_hex))?;
build_managed_agent_summary(
app,
&record,
&runtimes,
&personas,
&super::load_global_agent_config(app).unwrap_or_default(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core_pkg::private_managed_agent::{
Payload, PrivateConfig, PrivateIdentity, FORMAT, VERSION,
};
use serde_json::{json, Map};
fn payload(pubkey: &str, name: &str) -> Payload {
Payload {
format: FORMAT.into(),
version: VERSION,
agent_pubkey: pubkey.into(),
owner_pubkey: "11".repeat(32),
generation: 1,
previous_event_id: None,
updated_at: "2026-08-06T00:00:00Z".into(),
identity: PrivateIdentity {
private_key_nsec: "nsec-test".into(),
auth_tag: None,
},
config: PrivateConfig {
relay_url: "wss://relay.example".into(),
name: name.into(),
persona_id: None,
runtime: Some("goose".into()),
model: Some("m".into()),
provider: None,
system_prompt: Some("relay prompt".into()),
parallelism: None,
respond_to: None,
respond_to_allowlist: vec![],
agent_command_override: None,
agent_args: vec![],
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
env_vars: BTreeMap::new(),
backend: json!({"type":"local"}),
backend_agent_id: None,
team_id: None,
persona_name_in_team: None,
relay_mesh: None,
extra: Map::new(),
},
extensions: BTreeMap::new(),
extra: Map::new(),
}
}
#[test]
fn resolves_overlay_and_relay_only_without_mutating_local() {
let mut overlay = PrivateConfigOverlay::default();
overlay.insert(payload("aa", "relay local")).unwrap();
overlay.insert(payload("bb", "relay only")).unwrap();
let mut local = overlay.0["aa"].fresh_record();
local.name = "disk".into();
local.system_prompt = Some("disk prompt".into());
let original = local.clone();
let resolved = overlay.resolved_records(std::slice::from_ref(&local));
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].name, "relay local");
assert_eq!(resolved[0].system_prompt.as_deref(), Some("relay prompt"));
assert_eq!(resolved[1].pubkey, "bb");
assert!(!resolved[1].start_on_app_launch);
assert_eq!(local, original);
}
#[test]
fn rejected_patch_preserves_cached_value_and_clear_drops_scope() {
let mut overlay = PrivateConfigOverlay::default();
overlay.insert(payload("aa", "valid")).unwrap();
let mut invalid = payload("aa", "invalid");
invalid.config.backend = json!({"type":"provider"});
assert!(overlay.insert(invalid).is_err());
assert_eq!(overlay.resolved_records(&[])[0].name, "valid");
overlay.clear();
assert!(overlay.resolved_records(&[]).is_empty());
}
#[test]
fn unresolved_harness_has_named_refusal() {
let mut patch = PrivateConfigPatch::from_payload(payload("aa", "agent")).unwrap();
patch.runtime = Some("missing-custom-harness".into());
let record = patch.fresh_record();
let error = crate::managed_agents::try_record_agent_command(&record, &[]).unwrap_err();
assert_eq!(
crate::managed_agents::dangling_harness_id(&error),
Some("missing-custom-harness")
);
}
}

View File

@@ -26,8 +26,12 @@ use super::{
retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent},
ManagedAgentRecord,
};
use buzz_core_pkg::kind::KIND_MANAGED_AGENT;
use buzz_core_pkg::{
kind::{KIND_MANAGED_AGENT, KIND_PRIVATE_MANAGED_AGENT},
private_managed_agent::{self, Payload, PrivateConfig, PrivateIdentity},
};
use nostr::JsonUtil;
use std::collections::BTreeMap;
/// Reconcile `managed-agents.json` into kind:30177 events in the retention
/// store. Boot-time entry point, called from `event_sync::run_event_sync`
@@ -126,6 +130,22 @@ pub(crate) fn retain_agent_record(
conn: &rusqlite::Connection,
keys: &nostr::Keys,
record: &ManagedAgentRecord,
) -> Result<bool, String> {
let transaction = conn
.unchecked_transaction()
.map_err(|error| format!("failed to begin agent retention transaction: {error}"))?;
let public_changed = retain_public_agent_record(&transaction, keys, record)?;
let private_changed = retain_private_agent_record(&transaction, keys, record)?;
transaction
.commit()
.map_err(|error| format!("failed to commit agent retention transaction: {error}"))?;
Ok(public_changed || private_changed)
}
fn retain_public_agent_record(
conn: &rusqlite::Connection,
keys: &nostr::Keys,
record: &ManagedAgentRecord,
) -> Result<bool, String> {
let owner_pubkey = keys.public_key().to_hex();
let existing = get_retained_event(conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?;
@@ -165,5 +185,154 @@ pub(crate) fn retain_agent_record(
Ok(true)
}
fn retain_private_agent_record(
conn: &rusqlite::Connection,
keys: &nostr::Keys,
record: &ManagedAgentRecord,
) -> Result<bool, String> {
if record.private_key_nsec.is_empty() {
return Ok(false);
}
let owner_pubkey = keys.public_key().to_hex();
let existing = get_retained_event(
conn,
KIND_PRIVATE_MANAGED_AGENT,
&owner_pubkey,
&record.pubkey,
)?;
let previous_event = existing
.as_ref()
.and_then(|row| nostr::Event::from_json(&row.raw_event).ok());
let generation = previous_event
.as_ref()
.and_then(event_generation)
.unwrap_or(0)
.checked_add(1)
.ok_or_else(|| format!("private config generation overflow for '{}'", record.name))?;
let previous_event_id = previous_event.as_ref().map(|event| event.id.to_hex());
let created_at = monotonic_created_at(existing.as_ref().map(|row| row.created_at));
let mut payload =
private_payload_from_record(record, &owner_pubkey, generation, previous_event_id)?;
// Preserve fields authored by a newer client. This writer owns the known
// typed fields only; flatten/extension data must survive an older Desktop
// editing one known value.
let existing_payload = previous_event.as_ref().and_then(|existing_event| {
private_managed_agent::validate_and_decrypt(existing_event, keys)
.ok()
.map(|(_, payload)| payload)
});
if let Some(existing_payload) = &existing_payload {
payload.extensions.clone_from(&existing_payload.extensions);
payload.extra.clone_from(&existing_payload.extra);
payload
.config
.extra
.clone_from(&existing_payload.config.extra);
}
// NIP-44 encryption is randomized, so compare the validated plaintext
// payload rather than ciphertext. Metadata derived from the retained head
// changes only after a meaningful config mutation.
if existing_payload
.as_ref()
.is_some_and(|existing| private_payload_body_eq(existing, &payload))
{
return Ok(false);
}
let event = private_managed_agent::build_event(keys, &payload, created_at.as_secs())
.map_err(|e| format!("failed to build private config for '{}': {e}", record.name))?;
retain_event(
conn,
&RetainedEvent {
kind: KIND_PRIVATE_MANAGED_AGENT,
pubkey: owner_pubkey,
d_tag: record.pubkey.clone(),
content: event.content.clone(),
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: true,
},
)
.map_err(|e| format!("failed to retain private config for '{}': {e}", record.name))?;
Ok(true)
}
fn event_generation(event: &nostr::Event) -> Option<u64> {
event.tags.iter().find_map(|tag| {
let values = tag.as_slice();
(values.first().map(String::as_str) == Some("g"))
.then(|| values.get(1)?.parse().ok())
.flatten()
})
}
fn private_payload_from_record(
record: &ManagedAgentRecord,
owner_pubkey: &str,
generation: u64,
previous_event_id: Option<String>,
) -> Result<Payload, String> {
let backend = serde_json::to_value(&record.backend)
.map_err(|e| format!("failed to serialize backend for '{}': {e}", record.name))?;
let relay_mesh = record
.relay_mesh
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| format!("failed to serialize relay mesh for '{}': {e}", record.name))?;
Ok(Payload {
format: private_managed_agent::FORMAT.to_string(),
version: private_managed_agent::VERSION,
agent_pubkey: record.pubkey.clone(),
owner_pubkey: owner_pubkey.to_string(),
generation,
previous_event_id,
updated_at: record.updated_at.clone(),
identity: PrivateIdentity {
private_key_nsec: record.private_key_nsec.clone(),
auth_tag: record.auth_tag.clone(),
},
config: PrivateConfig {
relay_url: record.relay_url.clone(),
name: record.name.clone(),
persona_id: record.persona_id.clone(),
runtime: record.runtime.clone(),
model: record.model.clone(),
provider: record.provider.clone(),
system_prompt: record.system_prompt.clone(),
parallelism: Some(record.parallelism),
respond_to: Some(record.respond_to.as_str().to_string()),
respond_to_allowlist: record.respond_to_allowlist.clone(),
agent_command_override: record.agent_command_override.clone(),
agent_args: record.agent_args.clone(),
idle_timeout_seconds: record.idle_timeout_seconds,
max_turn_duration_seconds: record.max_turn_duration_seconds,
env_vars: record.env_vars.clone(),
backend,
backend_agent_id: record.backend_agent_id.clone(),
team_id: record.team_id.clone(),
persona_name_in_team: record.persona_name_in_team.clone(),
relay_mesh,
extra: serde_json::Map::new(),
},
extensions: BTreeMap::new(),
extra: serde_json::Map::new(),
})
}
fn private_payload_body_eq(left: &Payload, right: &Payload) -> bool {
left.agent_pubkey == right.agent_pubkey
&& left.owner_pubkey == right.owner_pubkey
&& left.identity == right.identity
&& left.config == right.config
&& left.extensions == right.extensions
&& left.extra == right.extra
}
#[cfg(test)]
mod tests;

View File

@@ -1,5 +1,6 @@
use super::*;
use crate::managed_agents::retention::{get_pending_sync, get_retained_event, mark_synced};
use nostr::ToBech32;
use std::collections::BTreeMap;
use tempfile::TempDir;
@@ -34,6 +35,159 @@ fn write_store(dir: &TempDir, records: &[ManagedAgentRecord]) {
.unwrap();
}
#[test]
fn private_config_conversion_encrypts_secrets_and_is_idempotent() {
let dir = TempDir::new().unwrap();
let owner_keys = nostr::Keys::generate();
let agent_keys = nostr::Keys::generate();
let pubkey = agent_keys.public_key().to_hex();
let mut record = sample_record(&pubkey, "private-agent");
record.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap();
record.env_vars = BTreeMap::from([("API_TOKEN".to_string(), "very-secret".to_string())]);
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
assert!(retain_agent_record(&conn, &owner_keys, &record).unwrap());
let row = get_retained_event(
&conn,
KIND_PRIVATE_MANAGED_AGENT,
&owner_keys.public_key().to_hex(),
&pubkey,
)
.unwrap()
.unwrap();
assert!(!row.raw_event.contains("very-secret"));
assert!(!row.raw_event.contains("nsec1"));
let event = nostr::Event::from_json(&row.raw_event).unwrap();
let (_, payload) = private_managed_agent::validate_and_decrypt(&event, &owner_keys).unwrap();
assert_eq!(payload.config.name, "private-agent");
assert_eq!(payload.config.env_vars["API_TOKEN"], "very-secret");
assert_eq!(payload.generation, 1);
assert_eq!(payload.previous_event_id, None);
mark_synced(
&conn,
row.kind,
&row.pubkey,
&row.d_tag,
row.created_at,
&row.content,
)
.unwrap();
assert!(!retain_agent_record(&conn, &owner_keys, &record).unwrap());
assert!(get_pending_sync(&conn)
.unwrap()
.iter()
.all(|pending| pending.kind != KIND_PRIVATE_MANAGED_AGENT));
}
#[test]
fn private_config_preserves_unknown_fields_without_generation_churn() {
let dir = TempDir::new().unwrap();
let owner_keys = nostr::Keys::generate();
let agent_keys = nostr::Keys::generate();
let pubkey = agent_keys.public_key().to_hex();
let mut record = sample_record(&pubkey, "private-agent");
record.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap();
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
let mut newer_payload =
private_payload_from_record(&record, &owner_keys.public_key().to_hex(), 1, None).unwrap();
newer_payload.extensions.insert(
"future.example:feature".into(),
serde_json::json!({"enabled": true}),
);
newer_payload
.extra
.insert("future_top_level".into(), serde_json::json!([1, 2, 3]));
newer_payload
.config
.extra
.insert("future_config".into(), serde_json::json!({"mode": "new"}));
let first_event = private_managed_agent::build_event(&owner_keys, &newer_payload, 1).unwrap();
retain_event(
&conn,
&RetainedEvent {
kind: KIND_PRIVATE_MANAGED_AGENT,
pubkey: owner_keys.public_key().to_hex(),
d_tag: pubkey.clone(),
content: first_event.content.clone(),
created_at: first_event.created_at.as_secs() as i64,
raw_event: first_event.as_json(),
pending_sync: false,
},
)
.unwrap();
record.system_prompt = Some("edited by an older client".into());
assert!(retain_agent_record(&conn, &owner_keys, &record).unwrap());
let row = get_retained_event(
&conn,
KIND_PRIVATE_MANAGED_AGENT,
&owner_keys.public_key().to_hex(),
&pubkey,
)
.unwrap()
.unwrap();
let rebuilt_event = nostr::Event::from_json(&row.raw_event).unwrap();
let (_, rebuilt) =
private_managed_agent::validate_and_decrypt(&rebuilt_event, &owner_keys).unwrap();
assert_eq!(rebuilt.generation, 2);
assert_eq!(rebuilt.previous_event_id, Some(first_event.id.to_hex()));
assert_eq!(rebuilt.extensions, newer_payload.extensions);
assert_eq!(rebuilt.extra, newer_payload.extra);
assert_eq!(rebuilt.config.extra, newer_payload.config.extra);
assert!(!retain_agent_record(&conn, &owner_keys, &record).unwrap());
let unchanged = get_retained_event(
&conn,
KIND_PRIVATE_MANAGED_AGENT,
&owner_keys.public_key().to_hex(),
&pubkey,
)
.unwrap()
.unwrap();
assert_eq!(unchanged.raw_event, row.raw_event);
}
#[test]
fn private_config_change_advances_generation_and_links_previous_event() {
let dir = TempDir::new().unwrap();
let owner_keys = nostr::Keys::generate();
let agent_keys = nostr::Keys::generate();
let pubkey = agent_keys.public_key().to_hex();
let mut record = sample_record(&pubkey, "private-agent");
record.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap();
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
retain_agent_record(&conn, &owner_keys, &record).unwrap();
let first = get_retained_event(
&conn,
KIND_PRIVATE_MANAGED_AGENT,
&owner_keys.public_key().to_hex(),
&pubkey,
)
.unwrap()
.unwrap();
let first_event = nostr::Event::from_json(&first.raw_event).unwrap();
record.env_vars.insert("TOKEN".into(), "rotated".into());
retain_agent_record(&conn, &owner_keys, &record).unwrap();
let second = get_retained_event(
&conn,
KIND_PRIVATE_MANAGED_AGENT,
&owner_keys.public_key().to_hex(),
&pubkey,
)
.unwrap()
.unwrap();
let second_event = nostr::Event::from_json(&second.raw_event).unwrap();
let (_, payload) =
private_managed_agent::validate_and_decrypt(&second_event, &owner_keys).unwrap();
assert_eq!(payload.generation, 2);
assert_eq!(payload.previous_event_id, Some(first_event.id.to_hex()));
}
#[test]
fn missing_store_is_noop() {
let dir = TempDir::new().unwrap();

View File

@@ -6,6 +6,7 @@ import {
KIND_DELETION,
KIND_MANAGED_AGENT,
KIND_PERSONA,
KIND_PRIVATE_MANAGED_AGENT,
KIND_TEAM,
} from "@/shared/constants/kinds";
import { startPersonaSync } from "./usePersonaSync.ts";
@@ -14,6 +15,7 @@ const EXPECTED_KINDS = [
KIND_PERSONA,
KIND_TEAM,
KIND_MANAGED_AGENT,
KIND_PRIVATE_MANAGED_AGENT,
KIND_DELETION,
];

View File

@@ -7,6 +7,7 @@ import {
KIND_DELETION,
KIND_MANAGED_AGENT,
KIND_PERSONA,
KIND_PRIVATE_MANAGED_AGENT,
KIND_TEAM,
} from "@/shared/constants/kinds";
@@ -17,6 +18,7 @@ const PERSONA_SYNC_KINDS = [
KIND_PERSONA,
KIND_TEAM,
KIND_MANAGED_AGENT,
KIND_PRIVATE_MANAGED_AGENT,
KIND_DELETION,
];

View File

@@ -53,6 +53,8 @@ export const KIND_COMMUNITY_THEME = 30078;
export const KIND_PERSONA = 30175;
export const KIND_TEAM = 30176;
export const KIND_MANAGED_AGENT = 30177;
// Owner-authored, owner-readable encrypted runnable configuration.
export const KIND_PRIVATE_MANAGED_AGENT = 30179;
export const KIND_USER_STATUS = 30315;
export const KIND_AGENT_OBSERVER_FRAME = 24200;
export const KIND_AGENT_TURN_METRIC = 44200;