mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
378
desktop/src-tauri/src/managed_agents/private_config_overlay.rs
Normal file
378
desktop/src-tauri/src/managed_agents/private_config_overlay.rs
Normal 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(¶llelism) {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user