mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(desktop): discover authenticated owned relay agents (#7122)
🤖 ## Summary An agent you own could be missing from **New message → To:** and **Channel members → Add people and agents** on a machine that has never managed it. This PR lets those existing lists find your agent without requiring a shared channel first. Desktop now checks records proving you own it, rather than looking only at agents in channels you've already joined. **No new screen or control is added.** For example, an agent with verified ownership and **Who can send instructions → Only me (default)** can now appear even with no shared channels. Each screen still applies its existing access rules; this does not make every discovered agent selectable everywhere. | Screen / control | Before | After this PR alone | | --- | --- | --- | | **New message → To:** recipient picker | An owned agent absent from this machine and shared-channel bot lists could be missing. | Its named **agent** row can appear; selecting it adds a recipient chip. This is recipient selection, not a guarantee that a later message will reach or wake the agent. | | **Channel members → Add people and agents** | The same agent could be missing from **Not in this channel** search results. | Its row can appear with the existing **Add** button. If you can add members, that button submits the existing channel-membership request; finding the row alone changes no membership. | | **Stream / forum composer → @ suggestions** | An owned agent already in the channel under an ordinary member role could be missing from agent suggestions. | Its actual membership is recognized without requiring the bot role. Agents not managed on this device still need membership in that channel. | | **Pulse → Agents** | An agent absent from both local management and the server's agent list was omitted from the count and author lookup. | The count and feed's author lookup can include it; notes appear only if it has published them. | Being listed does **not** mean the agent is online, add it to a channel, or grant local Start/Edit controls. For agents not managed on this device, global **Search** still excludes those configured for “Only me”, and DM @ selection is not added here. DM @ selection and message-driven nonmember invitation are addressed in [#7124](https://github.com/block/buzz/pull/7124); the standalone forum **Invite / Cancel** flow is in [#7125](https://github.com/block/buzz/pull/7125). <details> <summary>Ownership and membership checks</summary> A discovery lead is not proof: the latest agent profile must have a valid signature and exactly one valid ownership attestation—the owner's signed link to that agent. Its response policy must be signed by that verified owner; an invalid latest policy cannot restore an older permission. Membership comes separately from the latest server-signed roster, including removals. Existing profile cards, owner labels and agent-avatar shapes also use this stricter verification: malformed or forged evidence must not supply ownership/agent classification on its own. Valid ownership was already recognized; no profile-picture or badge design changes. Attestation time conditions apply to the signed event's timestamp, not a live expiry timer. Existing legacy compatibility and builds requiring verified owner policy retain their respective rules. Discovery and sending remain separate operations, not an atomic permission check. </details> ### Review corrections - When runtime and owner policy overlap, **explicit online/away/offline from the verified latest runtime is retained**. Policy still supplies ownership/permissions; claimed runtime membership is not restored. Missing/unrecognized status stays unknown, and invalid latest policy cannot revive runtime permissions. - Discovery without runtime evidence is now **unknown**, not offline: native conversion, both IPC adapters, Pulse, Projects and profile/session consumers preserve that distinction. Unknown has no status dot and is not promoted to a deployed/running agent. - Both relay-only picker paths retain the authenticated owner, including the existing **managed by you** label. The analogous global Search projection is fixed without changing its existing “anyone” filter. - Authorized stored profile activity remains visible when liveness becomes unknown/absent or the active turn ends. History reads do not start a live subscription, grant access, or imply current availability. ### Related issue Independent base: `main`. Child: [#7124](https://github.com/block/buzz/pull/7124), then [#7125](https://github.com/block/buzz/pull/7125). Extracted from [#7114](https://github.com/block/buzz/pull/7114), retained as historical source (`98fe33ec`). [Behavior contract](3a56d17824/docs/owned-agent-discovery.md). Originating [Buzz discussion](buzz://message?channel=f7a9536a-1738-4bad-a888-b3ea25010ef1&id=7aa1f0ab23dce514bd8a0221441cf005bf428914621171472b79747c50820848) · channel `f7a9536a-1738-4bad-a888-b3ea25010ef1`. ### Testing Current candidate: `3a56d17824522580fe04cae463b54f4c7ba66021`, a four-file native/test/doc runtime-status repair atop published `ae23c1c9680a881cee7eed94e259bf15bf8ce3f7`. Branch ancestry is main `1c8321cd08feb597f8bcff5195c21148fb3e98ed`; refreshed main `0e878664b08cdf7fb2d89d940bc2aa92cdc485f7` adds only the independent CI-workflow split. Read-only mergeability succeeds; this is not a tested merged-tree claim. **Local CI attempt and continuation (not an uninterrupted green run):** the new exact-head `just ci` passed formatting/static checks, workspace and Tauri clippy, workspace Rust tests, **5,910 desktop tests**, desktop production build and Tauri check. Its native main target finished **3,073 passed / 1 failed / 19 ignored** (exit 101): `cheap_discovery_reports_absent_before_any_forced_probe` saw a process-global login-shell counter of 2 instead of 0. The counter includes unrelated version/adapter probes whose tests do not hold the failed test's PATH mutex; no managed-agent discovery implementation changed in the runtime repair. The unchanged failing test then passed **three isolated invocations**. Only the failed native workspace lane was retried with `RUST_TEST_THREADS=1 just desktop-tauri-test`: **3,074 main-target tests passed / 19 ignored**, all additional workspace targets passed (exit 0). The previously unrun `just web-build mobile-test` tail then passed (exit 0; **2,019 mobile tests**). Earlier successful lanes were reused; no source/guard changes or blanket CI rerun. The original failure and all diagnostic/retry logs are retained. - **71 native `nostr_convert` tests pass**, including seven new production merge regressions: online/away/offline, missing/invalid status, policy-only, status-less latest replacement and forged latest replacement. Before production repair, those seven yielded **4 failures / 3 passing controls**. - Reused frontend evidence from `ae23c1c9` (frontend is unchanged): Desktop TypeScript and isolated E2E build pass; **9 browser tests / 0 retries**, covering both relay-only picker journeys and seven adjacent stop-control regressions. Real UI with mock Tauri IPC, not live relay/native webview. - Earlier `ae23c1c9` local `just ci` passed without failures, including 3,067 native main-target tests / 19 ignored and 2,019 mobile tests; not substituted for the new source gate above. - Reused unchanged repair evidence: **17 real-store/hook history regressions**, **161 focused tests**, and independent **9 mounted owner/bot/identity revocation/regrant transitions** with zero hook-phase native calls. The regression was falsified before repair (14 failures, 3 controls). - Signed local-server fixtures cover discovery with no local/shared record, ordinary-role membership, forged ownership, invalid signatures, duplicate authentication, wrong-owner/latest-invalid policy, revoked membership and wrong destinations. These establish native data checks, not a live agent response. GitHub checks and renewed technical/security review must apply to the current published head; earlier-head green checks are not replacement-head proof. Local source review is not formal code-owner/latest-push approval or exact-range security authorization. A green security workflow with substantive review skipped is not security clearance. ### Screenshots #### Relay-only picker evidence — `ae23c1c9680a881cee7eed94e259bf15bf8ce3f7` These cropped rows come from the two real production picker journeys in [`owned-agent-discovery.spec.ts`](ae23c1c968/desktop/tests/e2e/owned-agent-discovery.spec.ts), using mock Tauri IPC with **no local agents and no user-search duplicate**. The fixture supplies verified-owner data and unknown availability; the browser test checks its presentation, not native signature verification. Both exact-tip journeys pass without retries. No live relay, native webview, invitation, delivery or wakeup is claimed. Before the repair, both relay-only candidate constructors discarded the owner, so the existing “managed by you” label was absent. These are after-repair captures; no before image was captured. #### New Message → To The relay-only agent retains its authenticated owner label.  #### Channel members → Add people and agents The matching result retains “managed by you” beside the existing Add action; the test does not click Add or claim membership changed.  --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -20,6 +20,7 @@ export default defineConfig({
|
||||
name: "smoke",
|
||||
testMatch: [
|
||||
"**/smoke.spec.ts",
|
||||
"**/owned-agent-discovery.spec.ts",
|
||||
"**/thread-head-stale-edit.spec.ts",
|
||||
"**/sidebar-offcanvas-rail.spec.ts",
|
||||
"**/tooltip-semantics.spec.ts",
|
||||
|
||||
@@ -134,9 +134,25 @@ async fn list_relay_agents_for_selection(
|
||||
.await?
|
||||
.ok_or_else(|| "relay agent membership authority is unavailable".to_string())?;
|
||||
|
||||
// Membership is the authoritative and bounded candidate source. Only
|
||||
// channels visible to this identity are read, and only bot-role p-tags can
|
||||
// drive the downstream managed-policy and owner-profile lookups.
|
||||
// Owned identities are relay state, even when this Desktop has never run
|
||||
// them or they have not joined a channel yet. Owner-authored coordinates
|
||||
// seed discovery only; the agent's signed NIP-OA profile still has to
|
||||
// authenticate ownership below. Scope selection queries to the exact keys.
|
||||
let mut owned_filter = serde_json::json!({
|
||||
"kinds": [30177],
|
||||
"authors": [&viewer_pubkey],
|
||||
});
|
||||
if let Some(requested_pubkeys) = requested_pubkeys {
|
||||
owned_filter["#d"] = serde_json::json!(requested_pubkeys);
|
||||
}
|
||||
let owned_events = query_all_relay_pages(state, owned_filter)
|
||||
.await
|
||||
.map_err(|error| format!("relay owned-agent query failed: {error}"))?;
|
||||
let owned_candidates = nostr_convert::managed_agent_pubkeys_from_events(&owned_events);
|
||||
|
||||
// Membership remains authoritative and visible only to this viewer.
|
||||
// Known owned identities can have any membership role; other candidates
|
||||
// must still have explicit bot-role evidence.
|
||||
let mut membership_filter = serde_json::json!({
|
||||
"kinds": [39002],
|
||||
"authors": [&relay_pubkey],
|
||||
@@ -148,12 +164,21 @@ async fn list_relay_agents_for_selection(
|
||||
let membership_events = query_all_relay_pages(state, membership_filter)
|
||||
.await
|
||||
.map_err(|error| format!("relay agent channel-membership query failed: {error}"))?;
|
||||
let mut member_agent_channel_ids =
|
||||
nostr_convert::member_agent_channel_ids_from_events(&membership_events, &relay_pubkey);
|
||||
let mut member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events(
|
||||
&membership_events,
|
||||
&relay_pubkey,
|
||||
&owned_candidates,
|
||||
);
|
||||
if let Some(requested_pubkeys) = requested_pubkeys {
|
||||
member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey));
|
||||
}
|
||||
let candidate_pubkeys: Vec<String> = member_agent_channel_ids.keys().cloned().collect();
|
||||
let candidate_pubkeys: Vec<String> = member_agent_channel_ids
|
||||
.keys()
|
||||
.cloned()
|
||||
.chain(owned_candidates)
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
if candidate_pubkeys.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -206,7 +231,10 @@ async fn list_relay_agents_for_selection(
|
||||
&mut agents,
|
||||
crate::managed_agents::owner_only_access_build(),
|
||||
);
|
||||
agents.retain(|agent| member_agent_channel_ids.contains_key(&agent.pubkey));
|
||||
agents.retain(|agent| {
|
||||
member_agent_channel_ids.contains_key(&agent.pubkey)
|
||||
|| agent.owner_pubkey.as_deref() == Some(viewer_pubkey.as_str())
|
||||
});
|
||||
for agent in &mut agents {
|
||||
agent.channel_ids = member_agent_channel_ids
|
||||
.get(&agent.pubkey)
|
||||
@@ -569,3 +597,6 @@ mod real_relay_tests {
|
||||
assert_eq!(emitted_mentions, vec![agent.public_key().to_hex()]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod owned_tests;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Exercise the production query plan against a loopback relay with signed fixtures.
|
||||
use super::*;
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_owned_discovery_and_membership_do_not_require_local_records() {
|
||||
let _serial = crate::relay_admission::TEST_SERIAL.lock().await;
|
||||
crate::relay_admission::reset_rate_limit_gate();
|
||||
let relay = Keys::generate();
|
||||
let owner = Keys::generate();
|
||||
let agent = Keys::generate();
|
||||
let stranger = Keys::generate();
|
||||
let agent_key = agent.public_key().to_hex();
|
||||
let owner_key = owner.public_key().to_hex();
|
||||
let relay_key = relay.public_key().to_hex();
|
||||
let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap();
|
||||
let auth: Vec<String> = serde_json::from_str(&auth).unwrap();
|
||||
let profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Remote Scout"}"#)
|
||||
.tags([Tag::parse(auth).unwrap()])
|
||||
.sign_with_keys(&agent)
|
||||
.unwrap();
|
||||
let policy = |key: &str| {
|
||||
EventBuilder::new(
|
||||
Kind::Custom(30177),
|
||||
r#"{"name":"Remote Scout","parallelism":1,"respond_to":"owner-only"}"#,
|
||||
)
|
||||
.tags([Tag::parse(["d", key]).unwrap()])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap()
|
||||
};
|
||||
// An owner-authored coordinate is a discovery hint, not ownership proof.
|
||||
let forged = policy(&stranger.public_key().to_hex());
|
||||
let stranger_profile = EventBuilder::new(Kind::Metadata, "{}")
|
||||
.sign_with_keys(&stranger)
|
||||
.unwrap();
|
||||
let events = Arc::new(Mutex::new(vec![
|
||||
profile,
|
||||
policy(&agent_key),
|
||||
forged,
|
||||
stranger_profile,
|
||||
]));
|
||||
let queries = Arc::new(Mutex::new(Vec::<serde_json::Value>::new()));
|
||||
let query_events = events.clone();
|
||||
let query_log = queries.clone();
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(move || {
|
||||
let key = relay_key.clone();
|
||||
async move { Json(serde_json::json!({"self": key})) }
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/query",
|
||||
post(move |Json(filters): Json<Vec<serde_json::Value>>| {
|
||||
let events = query_events.clone();
|
||||
let queries = query_log.clone();
|
||||
async move {
|
||||
queries.lock().unwrap().extend(filters.clone());
|
||||
let events = events.lock().unwrap();
|
||||
let result: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
filters.iter().any(|filter| {
|
||||
filter["kinds"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!(event.kind.as_u16()))
|
||||
&& filter.get("authors").is_none_or(|authors| {
|
||||
authors
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!(event.pubkey.to_hex()))
|
||||
})
|
||||
&& ["d", "p"].iter().all(|tag| {
|
||||
filter.get(format!("#{tag}")).is_none_or(|values| {
|
||||
event.tags.iter().any(|t| {
|
||||
t.as_slice().first().map(String::as_str)
|
||||
== Some(*tag)
|
||||
&& t.as_slice().get(1).is_some_and(|value| {
|
||||
values
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!(value))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
Json(result)
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
let state = crate::app_state::build_app_state();
|
||||
*state.keys.lock().unwrap() = owner.clone();
|
||||
*state.relay_url_override.lock().unwrap() = Some(format!("ws://{address}"));
|
||||
|
||||
let discovered = list_relay_agents_for_state(&state).await.unwrap();
|
||||
assert_eq!(discovered.len(), 1, "forged ownership must not be admitted");
|
||||
assert_eq!(discovered[0].pubkey, agent_key);
|
||||
assert_eq!(
|
||||
discovered[0].owner_pubkey.as_deref(),
|
||||
Some(owner_key.as_str())
|
||||
);
|
||||
assert!(
|
||||
discovered[0].channel_ids.is_empty(),
|
||||
"discovery is not membership"
|
||||
);
|
||||
|
||||
let membership = EventBuilder::new(Kind::Custom(39002), "")
|
||||
.tags([
|
||||
Tag::parse(["d", "general"]).unwrap(),
|
||||
Tag::parse(["p", &owner_key, "", "member"]).unwrap(),
|
||||
Tag::parse(["p", &agent_key, "", "member"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&relay)
|
||||
.unwrap();
|
||||
events.lock().unwrap().push(membership);
|
||||
let requested = std::collections::HashSet::from([agent_key.clone()]);
|
||||
let admitted = list_relay_agents_for_selection(&state, Some(&requested), Some("general"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(admitted.len(), 1);
|
||||
assert_eq!(admitted[0].channel_ids, vec!["general".to_string()]);
|
||||
let outside = list_relay_agents_for_selection(&state, Some(&requested), Some("private-other"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outside.len(), 1);
|
||||
assert!(
|
||||
outside[0].channel_ids.is_empty(),
|
||||
"ownership cannot fabricate destination membership"
|
||||
);
|
||||
// A newer signed snapshot revokes membership, even if an old snapshot
|
||||
// is also returned. The owned identity remains discoverable, not admitted.
|
||||
let removed = EventBuilder::new(Kind::Custom(39002), "")
|
||||
.tags([
|
||||
Tag::parse(["d", "general"]).unwrap(),
|
||||
Tag::parse(["p", &owner_key, "", "member"]).unwrap(),
|
||||
])
|
||||
.custom_created_at(nostr::Timestamp::from(
|
||||
nostr::Timestamp::now().as_secs() + 1,
|
||||
))
|
||||
.sign_with_keys(&relay)
|
||||
.unwrap();
|
||||
events.lock().unwrap().push(removed);
|
||||
let revoked = list_relay_agents_for_selection(&state, Some(&requested), Some("general"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(revoked[0].channel_ids.is_empty());
|
||||
|
||||
let deny = EventBuilder::new(
|
||||
Kind::Custom(30177),
|
||||
r#"{"name":"Remote Scout","parallelism":1,"respond_to":"nobody"}"#,
|
||||
)
|
||||
.tags([Tag::parse(["d", &agent_key]).unwrap()])
|
||||
.custom_created_at(nostr::Timestamp::from(
|
||||
nostr::Timestamp::now().as_secs() + 2,
|
||||
))
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap();
|
||||
events.lock().unwrap().push(deny);
|
||||
let denied = list_relay_agents_for_selection(&state, Some(&requested), Some("general"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
denied.is_empty(),
|
||||
"latest unsupported policy cannot fall back to an older allow"
|
||||
);
|
||||
|
||||
assert!(queries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|filter| filter["kinds"] == serde_json::json!([30177])
|
||||
&& filter["authors"] == serde_json::json!([owner_key])
|
||||
&& filter.get("#d").is_none()));
|
||||
server.abort();
|
||||
crate::relay_admission::reset_rate_limit_gate();
|
||||
}
|
||||
@@ -57,30 +57,47 @@ fn tags_named<'a>(event: &'a Event, name: &'a str) -> impl Iterator<Item = &'a [
|
||||
|
||||
/// Return the owner pubkey from a valid NIP-OA owner tag on a kind:0 profile.
|
||||
///
|
||||
/// NIP-OA marks an agent identity by having the owner sign an `auth` tag for
|
||||
/// the agent pubkey. We verify the tag against the profile event author, not
|
||||
/// against the owner, so a forged or stale marker does not turn a person into
|
||||
/// an agent in mention search.
|
||||
/// NIP-OA requires a valid event and exactly one well-formed `auth` tag whose
|
||||
/// owner signature and every condition apply to the event. Time clauses constrain
|
||||
/// the profile's `created_at`, not the verifier's clock. Ownership is provenance;
|
||||
/// the agent's pubkey remains the author.
|
||||
pub(crate) fn profile_valid_oa_owner_pubkey(event: &Event) -> Option<String> {
|
||||
let target_hex = event.pubkey.to_hex();
|
||||
let Ok(target_pubkey) = nostr::PublicKey::from_hex(&target_hex) else {
|
||||
if event.kind != nostr::Kind::Metadata {
|
||||
return None;
|
||||
};
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
let slice = tag.as_slice();
|
||||
if slice.first().map(String::as_str) != Some("auth") || slice.len() != 4 {
|
||||
continue;
|
||||
}
|
||||
let Ok(json) = serde_json::to_string(slice) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(owner_pubkey) = buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &target_pubkey) {
|
||||
return Some(owner_pubkey.to_hex());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
let mut auth_tags = tags_named(event, "auth");
|
||||
let auth_tag = auth_tags.next()?;
|
||||
// Count malformed auth tags too: no first-valid-tag fallback is permitted.
|
||||
if auth_tags.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(auth_tag).ok()?;
|
||||
// The structural parser also enforces canonical lowercase key/signature hex.
|
||||
buzz_sdk_pkg::nip_oa::parse_auth_tag(&json).ok()?;
|
||||
event.verify().ok()?;
|
||||
let owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &event.pubkey).ok()?;
|
||||
let conditions = auth_tag.get(2)?;
|
||||
// Syntax/ranges were checked by the SDK; evaluate every signed clause as-is.
|
||||
let applies = conditions.is_empty()
|
||||
|| conditions.split('&').all(|clause| {
|
||||
if let Some(value) = clause.strip_prefix("kind=") {
|
||||
value.parse::<u16>() == Ok(event.kind.as_u16())
|
||||
} else if let Some(value) = clause.strip_prefix("created_at<") {
|
||||
value
|
||||
.parse::<u64>()
|
||||
.is_ok_and(|bound| event.created_at.as_secs() < bound)
|
||||
} else if let Some(value) = clause.strip_prefix("created_at>") {
|
||||
value
|
||||
.parse::<u64>()
|
||||
.is_ok_and(|bound| event.created_at.as_secs() > bound)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
applies.then(|| owner.to_hex())
|
||||
}
|
||||
|
||||
pub(crate) fn profile_has_valid_oa_owner(event: &Event) -> bool {
|
||||
@@ -588,3 +605,6 @@ fn days_to_ymd(days: i64) -> (i64, u32, u32) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod oa_profile_tests;
|
||||
|
||||
@@ -14,6 +14,7 @@ use super::{agents_from_events, first_tag_value, profile_valid_oa_owner_pubkey,
|
||||
pub fn managed_agent_pubkeys_from_events(events: &[Event]) -> std::collections::HashSet<String> {
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| event.kind == nostr::Kind::Custom(30177) && event.verify().is_ok())
|
||||
.filter_map(|event| first_tag_value(event, "d"))
|
||||
.filter_map(|pubkey| nostr::PublicKey::from_hex(pubkey).ok())
|
||||
.map(|pubkey| pubkey.to_hex())
|
||||
@@ -40,9 +41,25 @@ fn relay_agents_from_legacy_events(events: &[Event]) -> Vec<RelayAgentInfo> {
|
||||
latest
|
||||
.into_values()
|
||||
.filter_map(|event| {
|
||||
if event.kind != nostr::Kind::Custom(10100) || event.verify().is_err() {
|
||||
return None;
|
||||
}
|
||||
let value = agents_from_events(std::slice::from_ref(event));
|
||||
let mut agent: RelayAgentInfo =
|
||||
serde_json::from_value(value.get("agents")?.as_array()?.first()?.clone()).ok()?;
|
||||
// The generic converter defaults missing status to offline for
|
||||
// compatibility. Discovery must retain only explicit, known runtime
|
||||
// evidence from this verified latest event, never that fallback.
|
||||
agent.status = serde_json::from_str::<serde_json::Value>(&event.content)
|
||||
.ok()
|
||||
.and_then(|content| {
|
||||
content
|
||||
.get("status")?
|
||||
.as_str()
|
||||
.filter(|status| matches!(*status, "online" | "away" | "offline"))
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
// Legacy directory entries are not authenticated managed-policy
|
||||
// coordinates, so they must not drive the live 30177 watcher.
|
||||
agent.owner_pubkey = None;
|
||||
@@ -67,11 +84,15 @@ pub fn relay_agents_from_directory_events(
|
||||
.into_iter()
|
||||
.map(|agent| (agent.pubkey.clone(), agent))
|
||||
.collect();
|
||||
for agent_pubkey in verified_policies.keys() {
|
||||
agents.remove(agent_pubkey);
|
||||
}
|
||||
for (agent_pubkey, event) in verified_policies {
|
||||
if let Some(agent) = relay_agent_from_managed_policy(&agent_pubkey, event) {
|
||||
// Remove even when policy parsing fails: invalid latest policy must not
|
||||
// revive runtime permissions. Only verified runtime liveness survives
|
||||
// a valid policy overlay; ownership, permissions and membership do not.
|
||||
let runtime = agents.remove(&agent_pubkey);
|
||||
if let Some(mut agent) = relay_agent_from_managed_policy(&agent_pubkey, event) {
|
||||
if let Some(runtime) = runtime {
|
||||
agent.status = runtime.status;
|
||||
}
|
||||
agents.insert(agent_pubkey, agent);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +117,9 @@ pub fn verified_agent_owners_from_profiles(events: &[Event]) -> HashMap<String,
|
||||
latest_profiles
|
||||
.into_iter()
|
||||
.filter_map(|(agent_pubkey, profile)| {
|
||||
if profile.kind != nostr::Kind::Metadata || profile.verify().is_err() {
|
||||
return None;
|
||||
}
|
||||
profile_valid_oa_owner_pubkey(profile).map(|owner| (agent_pubkey, owner))
|
||||
})
|
||||
.collect()
|
||||
@@ -126,6 +150,11 @@ fn latest_verified_managed_policies<'a>(
|
||||
}
|
||||
|
||||
fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option<RelayAgentInfo> {
|
||||
// Check the envelope as well as the declared author. Keep invalid latest
|
||||
// coordinates reserved above so they cannot revive older legacy permissions.
|
||||
if event.kind != nostr::Kind::Custom(30177) || event.verify().is_err() {
|
||||
return None;
|
||||
}
|
||||
let content = managed_agent_content_from_event(event).ok()?;
|
||||
Some(RelayAgentInfo {
|
||||
pubkey: agent_pubkey.to_string(),
|
||||
@@ -135,7 +164,8 @@ fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option<
|
||||
channels: Vec::new(),
|
||||
channel_ids: Vec::new(),
|
||||
capabilities: Vec::new(),
|
||||
status: "offline".to_string(),
|
||||
// Ownership/policy proves discovery, not conversational liveness.
|
||||
status: "unknown".to_string(),
|
||||
respond_to: Some(content.respond_to),
|
||||
respond_to_allowlist: content.respond_to_allowlist,
|
||||
})
|
||||
@@ -157,28 +187,48 @@ pub fn relay_agents_from_managed_agent_events(
|
||||
}
|
||||
|
||||
/// Build a pubkey-to-channel-id candidate map from relay-signed membership
|
||||
/// events. Only p-tags explicitly marked with the `bot` role are agents.
|
||||
/// events. Known agent identities need not have the cosmetic `bot` role;
|
||||
/// otherwise only explicit bot tags seed discovery.
|
||||
pub fn member_agent_channel_ids_from_events(
|
||||
events: &[Event],
|
||||
relay_pubkey: &str,
|
||||
known_agent_pubkeys: &std::collections::HashSet<String>,
|
||||
) -> HashMap<String, Vec<String>> {
|
||||
let mut channel_ids: HashMap<String, BTreeSet<String>> = HashMap::new();
|
||||
let mut latest: HashMap<String, &Event> = HashMap::new();
|
||||
for event in events {
|
||||
if !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey) {
|
||||
if event.kind != nostr::Kind::Custom(39002)
|
||||
|| !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey)
|
||||
|| event.verify().is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(channel_id) = first_tag_value(event, "d") else {
|
||||
continue;
|
||||
};
|
||||
if latest
|
||||
.get(channel_id)
|
||||
.is_none_or(|previous| event_is_newer(event, previous))
|
||||
{
|
||||
latest.insert(channel_id.to_string(), event);
|
||||
}
|
||||
}
|
||||
let mut channel_ids: HashMap<String, BTreeSet<String>> = HashMap::new();
|
||||
for (channel_id, event) in latest {
|
||||
for tag in tags_named(event, "p") {
|
||||
let (Some(pubkey), Some(role)) = (tag.get(1), tag.get(3)) else {
|
||||
let Some(pubkey) = tag
|
||||
.get(1)
|
||||
.and_then(|key| nostr::PublicKey::from_hex(key).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if role != "bot" || nostr::PublicKey::from_hex(pubkey).is_err() {
|
||||
let pubkey = pubkey.to_hex();
|
||||
if tag.get(3).map(String::as_str) != Some("bot")
|
||||
&& !known_agent_pubkeys.contains(&pubkey)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
channel_ids
|
||||
.entry(pubkey.clone())
|
||||
.entry(pubkey)
|
||||
.or_default()
|
||||
.insert(channel_id.to_string());
|
||||
}
|
||||
|
||||
216
desktop/src-tauri/src/nostr_convert/oa_profile_tests.rs
Normal file
216
desktop/src-tauri/src/nostr_convert/oa_profile_tests.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
//! NIP-OA profile regressions. All keys, timestamps and Schnorr nonces are
|
||||
//! synthetic and fixed; these fixtures require no clock, RNG, relay or config.
|
||||
|
||||
use nostr::hashes::{sha256, Hash};
|
||||
use nostr::secp256k1::{schnorr::Signature, Keypair, Message};
|
||||
use nostr::{Event, EventBuilder, Keys, Kind, SecretKey, Tag, Timestamp, SECP256K1};
|
||||
|
||||
use super::{
|
||||
profile_has_valid_oa_owner, profile_info_from_event, profile_valid_oa_owner_pubkey, tags_named,
|
||||
user_search_result_from_event, users_batch_from_events, verified_agent_owners_from_profiles,
|
||||
};
|
||||
|
||||
const CREATED_AT: u64 = 1_700_000_000;
|
||||
|
||||
// Public test scalars, matching the owner/agent identities in NIP-OA's vectors.
|
||||
fn keys(scalar: u8) -> Keys {
|
||||
let mut bytes = [0; 32];
|
||||
bytes[31] = scalar;
|
||||
Keys::new(SecretKey::from_slice(&bytes).unwrap())
|
||||
}
|
||||
|
||||
fn sign(keys: &Keys, message: Message) -> Signature {
|
||||
let keypair = Keypair::from_secret_key(SECP256K1, keys.secret_key());
|
||||
SECP256K1.sign_schnorr_no_aux_rand(&message, &keypair)
|
||||
}
|
||||
|
||||
// Intentionally bypass the SDK's *creation* validation so malformed conditions
|
||||
// and self-attestation can have genuine signatures and exercise verification.
|
||||
fn auth_tag_for(owner: &Keys, agent: &Keys, conditions: &str) -> Tag {
|
||||
let preimage = format!(
|
||||
"nostr:agent-auth:{}:{conditions}",
|
||||
agent.public_key().to_hex()
|
||||
);
|
||||
let digest = sha256::Hash::hash(preimage.as_bytes()).to_byte_array();
|
||||
let signature = sign(owner, Message::from_digest(digest));
|
||||
Tag::parse(vec![
|
||||
"auth".to_string(),
|
||||
owner.public_key().to_hex(),
|
||||
conditions.to_string(),
|
||||
signature.to_string(),
|
||||
])
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn auth_tag(conditions: &str) -> Tag {
|
||||
auth_tag_for(&keys(1), &keys(2), conditions)
|
||||
}
|
||||
|
||||
fn event(kind: Kind, created_at: u64, tags: Vec<Tag>) -> Event {
|
||||
let agent = keys(2);
|
||||
let mut unsigned = EventBuilder::new(kind, r#"{"display_name":"Synthetic agent"}"#)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.build(agent.public_key());
|
||||
let signature = sign(&agent, Message::from_digest(unsigned.id().to_bytes()));
|
||||
unsigned.add_signature(signature).unwrap()
|
||||
}
|
||||
|
||||
fn profile(tags: Vec<Tag>) -> Event {
|
||||
event(Kind::Metadata, CREATED_AT, tags)
|
||||
}
|
||||
|
||||
fn assert_ownership(event: &Event, expected: Option<String>) {
|
||||
assert_eq!(profile_valid_oa_owner_pubkey(event), expected);
|
||||
assert_eq!(profile_has_valid_oa_owner(event), expected.is_some());
|
||||
|
||||
let info = profile_info_from_event(event).unwrap();
|
||||
assert_eq!(info.owner_pubkey, expected);
|
||||
assert_eq!(info.pubkey, event.pubkey.to_hex());
|
||||
let search = user_search_result_from_event(event);
|
||||
assert_eq!(search.owner_pubkey, expected);
|
||||
assert_eq!(search.is_agent, expected.is_some());
|
||||
assert_eq!(search.pubkey, event.pubkey.to_hex());
|
||||
let pubkey = event.pubkey.to_hex();
|
||||
let batch = users_batch_from_events(std::slice::from_ref(event), std::slice::from_ref(&pubkey));
|
||||
assert_eq!(batch.profiles[&pubkey].owner_pubkey, expected);
|
||||
assert_eq!(batch.profiles[&pubkey].is_agent, expected.is_some());
|
||||
let owners = verified_agent_owners_from_profiles(std::slice::from_ref(event));
|
||||
assert_eq!(owners.get(&pubkey), expected.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_unconditional_and_applicable_conditional_ownership() {
|
||||
for conditions in [
|
||||
"",
|
||||
"kind=0",
|
||||
"created_at>1699999999&kind=0&created_at<1700000001",
|
||||
"created_at<1700000001&created_at>1699999999&kind=0&kind=0",
|
||||
] {
|
||||
let tag = auth_tag(conditions);
|
||||
// Check that deterministic fixture signing agrees with the SDK verifier.
|
||||
let json = serde_json::to_string(tag.as_slice()).unwrap();
|
||||
assert_eq!(
|
||||
buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &keys(2).public_key()).unwrap(),
|
||||
keys(1).public_key()
|
||||
);
|
||||
assert_ownership(&profile(vec![tag]), Some(keys(1).public_key().to_hex()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_auth_tags_including_malformed_tags_in_either_order() {
|
||||
let valid = auth_tag("");
|
||||
let malformed = Tag::parse(["auth"]).unwrap();
|
||||
for tags in [
|
||||
vec![valid.clone(), valid.clone()],
|
||||
vec![valid.clone(), auth_tag("kind=0")],
|
||||
vec![valid.clone(), malformed.clone()],
|
||||
vec![malformed, valid],
|
||||
] {
|
||||
let event = profile(tags);
|
||||
assert_eq!(tags_named(&event, "auth").count(), 2);
|
||||
assert_ownership(&event, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_kind_condition_and_conflicting_clauses() {
|
||||
for conditions in ["kind=1", "kind=0&kind=1", "kind=1&kind=0"] {
|
||||
assert_ownership(&profile(vec![auth_tag(conditions)]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_bounds_are_strict_and_use_event_time_not_wall_clock() {
|
||||
let tag = auth_tag("created_at>1699999999&created_at<1700000001");
|
||||
for (timestamp, accepted) in [
|
||||
(1_699_999_998, false),
|
||||
(1_699_999_999, false),
|
||||
(CREATED_AT, true),
|
||||
(1_700_000_001, false),
|
||||
(1_700_000_002, false),
|
||||
(u64::from(u32::MAX) + 1, false),
|
||||
] {
|
||||
let event = event(Kind::Metadata, timestamp, vec![tag.clone()]);
|
||||
let expected = accepted.then(|| keys(1).public_key().to_hex());
|
||||
assert_ownership(&event, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_tag_shapes_and_hex() {
|
||||
let valid = auth_tag("").as_slice().to_vec();
|
||||
let mut extra = valid.clone();
|
||||
extra.push("extra".to_string());
|
||||
let mut bad_owner = valid.clone();
|
||||
bad_owner[1] = "not-a-pubkey".to_string();
|
||||
let mut uppercase_owner = valid.clone();
|
||||
uppercase_owner[1] = uppercase_owner[1].to_uppercase();
|
||||
let mut uppercase_signature = valid.clone();
|
||||
uppercase_signature[3] = uppercase_signature[3].to_uppercase();
|
||||
let mut bad_signature = valid.clone();
|
||||
bad_signature[3] = "00".repeat(64);
|
||||
for values in [
|
||||
vec!["auth".to_string()],
|
||||
valid[..3].to_vec(),
|
||||
extra,
|
||||
bad_owner,
|
||||
uppercase_owner,
|
||||
uppercase_signature,
|
||||
bad_signature,
|
||||
] {
|
||||
assert_ownership(&profile(vec![Tag::parse(values).unwrap()]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_signed_but_malformed_conditions() {
|
||||
for conditions in [
|
||||
"kind=0&",
|
||||
"&kind=0",
|
||||
"kind=0&&kind=0",
|
||||
"kind=00",
|
||||
"kind=65536",
|
||||
"kind=0 ",
|
||||
"kind=٠",
|
||||
"Kind=0",
|
||||
"created_at=1700000000",
|
||||
"created_at<4294967296",
|
||||
"created_at>-1",
|
||||
"unsupported=0",
|
||||
] {
|
||||
assert_ownership(&profile(vec![auth_tag(conditions)]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_absent_authority_self_attestation_and_wrong_agent_binding() {
|
||||
assert_ownership(&profile(vec![]), None);
|
||||
assert_ownership(
|
||||
&profile(vec![
|
||||
Tag::parse(["owner", &keys(1).public_key().to_hex()]).unwrap()
|
||||
]),
|
||||
None,
|
||||
);
|
||||
assert_ownership(&profile(vec![auth_tag_for(&keys(2), &keys(2), "")]), None);
|
||||
assert_ownership(&profile(vec![auth_tag_for(&keys(1), &keys(3), "")]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_profile_and_invalid_event_even_with_valid_auth_tag() {
|
||||
assert_ownership(&event(Kind::TextNote, CREATED_AT, vec![auth_tag("")]), None);
|
||||
|
||||
let mut wrong_id = profile(vec![auth_tag("")]);
|
||||
wrong_id.content = r#"{"display_name":"Tampered"}"#.to_string();
|
||||
assert!(wrong_id.verify().is_err());
|
||||
assert_ownership(&wrong_id, None);
|
||||
|
||||
let mut wrong_signature = profile(vec![auth_tag("")]);
|
||||
wrong_signature.sig = sign(
|
||||
&keys(3),
|
||||
Message::from_digest(wrong_signature.id.to_bytes()),
|
||||
);
|
||||
assert!(wrong_signature.verify().is_err());
|
||||
assert_ownership(&wrong_signature, None);
|
||||
}
|
||||
143
desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs
Normal file
143
desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
//! Bind availability provenance to the production runtime/policy merge.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (Keys, Event, Event) {
|
||||
let owner = Keys::generate();
|
||||
let agent = Keys::generate();
|
||||
let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "")
|
||||
.expect("compute ownership");
|
||||
let values: Vec<String> = serde_json::from_str(&auth).expect("parse ownership");
|
||||
let profile = EventBuilder::new(Kind::Metadata, "{}")
|
||||
.tags([Tag::parse(values).expect("ownership tag")])
|
||||
.sign_with_keys(&agent)
|
||||
.expect("sign identity");
|
||||
let policy = managed_agent_event(
|
||||
&owner,
|
||||
&agent.public_key().to_hex(),
|
||||
"Policy name",
|
||||
"allowlist",
|
||||
&["a".repeat(64)],
|
||||
);
|
||||
(agent, profile, policy)
|
||||
}
|
||||
|
||||
fn runtime(keys: &Keys, status: Option<Value>, timestamp: u64) -> Event {
|
||||
let mut content = serde_json::json!({
|
||||
"name": "Runtime name",
|
||||
"owner_pubkey": "b".repeat(64),
|
||||
"respond_to": "anyone",
|
||||
"respond_to_allowlist": ["b".repeat(64)],
|
||||
"channels": ["Untrusted"],
|
||||
"channel_ids": ["untrusted-channel"],
|
||||
"capabilities": ["untrusted-capability"]
|
||||
});
|
||||
if let Some(status) = status {
|
||||
content["status"] = status;
|
||||
}
|
||||
EventBuilder::new(Kind::Custom(10100), content.to_string())
|
||||
.custom_created_at(nostr::Timestamp::from(timestamp))
|
||||
.sign_with_keys(keys)
|
||||
.expect("sign runtime")
|
||||
}
|
||||
|
||||
fn assert_merge(directory: &[Event], profile: &Event, policy: &Event, status: &str) {
|
||||
let merged = relay_agents_from_directory_events(
|
||||
directory,
|
||||
std::slice::from_ref(policy),
|
||||
std::slice::from_ref(profile),
|
||||
);
|
||||
assert_eq!(merged.len(), 1);
|
||||
let agent = &merged[0];
|
||||
assert_eq!(agent.status, status);
|
||||
assert_eq!(serde_json::to_value(agent).unwrap()["status"], status);
|
||||
assert_eq!(agent.pubkey, profile.pubkey.to_hex());
|
||||
assert_eq!(agent.owner_pubkey, Some(policy.pubkey.to_hex()));
|
||||
assert_eq!(agent.name, "Policy name");
|
||||
assert_eq!(
|
||||
agent.respond_to,
|
||||
Some(crate::managed_agents::RespondTo::Allowlist)
|
||||
);
|
||||
assert_eq!(agent.respond_to_allowlist, vec!["a".repeat(64)]);
|
||||
assert!(
|
||||
agent.channel_ids.is_empty(),
|
||||
"runtime cannot grant membership"
|
||||
);
|
||||
assert!(agent.channels.is_empty());
|
||||
assert!(agent.capabilities.is_empty());
|
||||
}
|
||||
|
||||
fn assert_known_status(status: &str) {
|
||||
let (keys, profile, policy) = fixture();
|
||||
assert_merge(
|
||||
&[runtime(&keys, Some(json!(status)), 10)],
|
||||
&profile,
|
||||
&policy,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_preserves_signed_online_runtime() {
|
||||
assert_known_status("online");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_preserves_signed_away_runtime() {
|
||||
assert_known_status("away");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_preserves_signed_offline_runtime() {
|
||||
assert_known_status("offline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_unrecognized_runtime_status_is_unknown() {
|
||||
let (keys, profile, policy) = fixture();
|
||||
for status in [
|
||||
None,
|
||||
Some(Value::Null),
|
||||
Some(json!(42)),
|
||||
Some(json!("busy")),
|
||||
Some(json!("unknown")),
|
||||
] {
|
||||
let directory = runtime(&keys, status, 10);
|
||||
assert_merge(
|
||||
std::slice::from_ref(&directory),
|
||||
&profile,
|
||||
&policy,
|
||||
"unknown",
|
||||
);
|
||||
let legacy = relay_agents_from_directory_events(&[directory], &[], &[]);
|
||||
assert_eq!(legacy[0].status, "unknown", "no default offline evidence");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_only_has_unknown_availability() {
|
||||
let (_, profile, policy) = fixture();
|
||||
assert_merge(&[], &profile, &policy, "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_runtime_without_status_does_not_revive_older_online_status() {
|
||||
let (keys, profile, policy) = fixture();
|
||||
let online = runtime(&keys, Some(json!("online")), 10);
|
||||
let missing = runtime(&keys, None, 20);
|
||||
for directory in [[online.clone(), missing.clone()], [missing, online]] {
|
||||
assert_merge(&directory, &profile, &policy, "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_latest_runtime_cannot_supply_or_revive_availability() {
|
||||
let (keys, profile, policy) = fixture();
|
||||
let old = runtime(&keys, Some(json!("online")), 10);
|
||||
let new = runtime(&keys, Some(json!("away")), 20);
|
||||
let mut value = serde_json::to_value(new).unwrap();
|
||||
value["content"] = json!(r#"{"status":"online"}"#);
|
||||
let forged: Event = serde_json::from_value(value).unwrap();
|
||||
assert!(forged.verify().is_err());
|
||||
assert_merge(&[old, forged], &profile, &policy, "unknown");
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
#[path = "runtime_policy_tests.rs"]
|
||||
mod runtime_policy_tests;
|
||||
|
||||
/// Build a signed event for testing with the given kind, content, and tags.
|
||||
fn ev(kind: u16, content: &str, tags: Vec<Vec<&str>>) -> Event {
|
||||
let keys = Keys::generate();
|
||||
@@ -437,6 +440,11 @@ fn managed_agent_directory_accepts_only_the_verified_owner_policy() {
|
||||
assert_eq!(agents.len(), 1);
|
||||
assert_eq!(agents[0].pubkey, agent_pubkey);
|
||||
assert_eq!(agents[0].name, "Codex");
|
||||
assert_eq!(agents[0].status, "unknown");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&agents[0]).unwrap()["status"],
|
||||
"unknown"
|
||||
);
|
||||
assert_eq!(
|
||||
agents[0].respond_to,
|
||||
Some(crate::managed_agents::RespondTo::Allowlist)
|
||||
@@ -510,8 +518,11 @@ fn managed_agent_candidates_use_only_relay_signed_bot_membership() {
|
||||
vec![vec!["d", "forged"], vec!["p", &agent_pubkey, "", "bot"]],
|
||||
);
|
||||
|
||||
let channel_ids =
|
||||
member_agent_channel_ids_from_events(&[forged, general], &relay_keys.public_key().to_hex());
|
||||
let channel_ids = member_agent_channel_ids_from_events(
|
||||
&[forged, general],
|
||||
&relay_keys.public_key().to_hex(),
|
||||
&Default::default(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
channel_ids.get(&agent_pubkey),
|
||||
@@ -760,3 +771,62 @@ fn timestamp_to_iso_known_value() {
|
||||
// Epoch
|
||||
assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_owned_agents_have_membership_independent_of_role() {
|
||||
let relay = Keys::generate();
|
||||
let agent = Keys::generate().public_key().to_hex();
|
||||
let event = EventBuilder::new(Kind::Custom(39002), "")
|
||||
.tags([
|
||||
Tag::parse(["d", "general"]).unwrap(),
|
||||
Tag::parse(["p", &agent, "", "member"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&relay)
|
||||
.unwrap();
|
||||
let memberships = member_agent_channel_ids_from_events(
|
||||
&[event],
|
||||
&relay.public_key().to_hex(),
|
||||
&std::collections::HashSet::from([agent.clone()]),
|
||||
);
|
||||
assert_eq!(memberships.get(&agent), Some(&vec!["general".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_directory_rejects_tampered_event_envelopes() {
|
||||
let agent = Keys::generate();
|
||||
let owner = Keys::generate();
|
||||
let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap();
|
||||
let auth: Vec<String> = serde_json::from_str(&auth).unwrap();
|
||||
let profile = EventBuilder::new(Kind::Metadata, "{}")
|
||||
.tags([Tag::parse(auth).unwrap()])
|
||||
.sign_with_keys(&agent)
|
||||
.unwrap();
|
||||
let policy = managed_agent_event(
|
||||
&owner,
|
||||
&agent.public_key().to_hex(),
|
||||
"Scout",
|
||||
"owner-only",
|
||||
&[],
|
||||
);
|
||||
let tamper = |event: &Event, content: &str| -> Event {
|
||||
let mut value = serde_json::to_value(event).unwrap();
|
||||
value["content"] = serde_json::json!(content);
|
||||
serde_json::from_value(value).unwrap()
|
||||
};
|
||||
let forged_policy = tamper(
|
||||
&policy,
|
||||
r#"{"name":"Scout","parallelism":1,"respond_to":"anyone"}"#,
|
||||
);
|
||||
assert!(forged_policy.verify().is_err());
|
||||
assert!(
|
||||
relay_agents_from_managed_agent_events(&[forged_policy], std::slice::from_ref(&profile),)
|
||||
.is_empty(),
|
||||
"an owner pubkey string is not an owner signature"
|
||||
);
|
||||
let forged_profile = tamper(&profile, r#"{"name":"forged"}"#);
|
||||
assert!(forged_profile.verify().is_err());
|
||||
assert!(
|
||||
relay_agents_from_managed_agent_events(&[policy], &[forged_profile],).is_empty(),
|
||||
"a valid OA tag does not authenticate the profile envelope"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -40,7 +39,8 @@ import {
|
||||
import { buildTranscriptState } from "./agentSessionTranscript";
|
||||
|
||||
type ManagedAgentSessionPanelProps = {
|
||||
agent: Pick<ManagedAgent, "pubkey" | "name" | "status"> & {
|
||||
agent: Pick<ManagedAgent, "pubkey" | "name"> & {
|
||||
status: ManagedAgent["status"] | "unknown";
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
autoTail?: boolean;
|
||||
@@ -76,7 +76,7 @@ export function ManagedAgentSessionPanel({
|
||||
rawEventsOverride,
|
||||
transcriptOverride,
|
||||
}: ManagedAgentSessionPanelProps) {
|
||||
const hasObserver = isManagedAgentActive(agent);
|
||||
const hasObserver = agent.status === "running" || agent.status === "deployed";
|
||||
// Always read from the store — archived frames are ingested regardless of
|
||||
// live status and must be renderable for idle agents with channel history.
|
||||
// The `hasObserver` flag still gates the relay subscription (via the
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import {
|
||||
mergeObserverEventWindows,
|
||||
observerEventScrollId,
|
||||
@@ -98,7 +97,7 @@ export function AgentSessionThreadPanel({
|
||||
widthPx,
|
||||
transparentChrome = false,
|
||||
}: AgentSessionThreadPanelProps) {
|
||||
const isLive = isManagedAgentActive(agent);
|
||||
const isLive = agent.status === "running" || agent.status === "deployed";
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
const sessionChannelId = channelId ?? channel?.id ?? null;
|
||||
// Unified working signal, scoped to this panel's channel (or all channels
|
||||
|
||||
@@ -332,7 +332,7 @@ export function MembersSidebar({
|
||||
displayName: agent.name,
|
||||
avatarUrl: null,
|
||||
nip05Handle: null,
|
||||
ownerPubkey: null,
|
||||
ownerPubkey: agent.ownerPubkey,
|
||||
isAgent: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { buildChannelAgentSessionCandidates } from "./useChannelAgentSessions.ts";
|
||||
import { useActiveAgentPubkeys } from "../../messages/lib/useActiveAgentPubkeys.ts";
|
||||
|
||||
const relayAgents = ["unknown", "online", "away", "offline"].map((status) => ({
|
||||
pubkey: status,
|
||||
name: status,
|
||||
status,
|
||||
channelIds: [],
|
||||
channels: [],
|
||||
}));
|
||||
|
||||
test("session projection retains unknown rather than manufacturing deployed status", () => {
|
||||
const candidates = buildChannelAgentSessionCandidates({
|
||||
managedAgents: [],
|
||||
relayAgents,
|
||||
});
|
||||
assert.deepEqual(
|
||||
candidates.map(({ status }) => status),
|
||||
["unknown", "deployed", "deployed", "stopped"],
|
||||
);
|
||||
});
|
||||
|
||||
test("active-agent lookup requires positive relay liveness evidence", () => {
|
||||
let active;
|
||||
function Probe() {
|
||||
active = useActiveAgentPubkeys([], relayAgents);
|
||||
return null;
|
||||
}
|
||||
renderToStaticMarkup(React.createElement(Probe));
|
||||
assert.deepEqual([...active], ["online", "away"]);
|
||||
});
|
||||
@@ -19,10 +19,8 @@ import {
|
||||
} from "./agentSessionSelection";
|
||||
import type { PanelValueSetter } from "./useChannelPanelHistoryState";
|
||||
|
||||
export type ChannelAgentSessionAgent = Pick<
|
||||
ManagedAgent,
|
||||
"pubkey" | "name" | "status"
|
||||
> & {
|
||||
export type ChannelAgentSessionAgent = Pick<ManagedAgent, "pubkey" | "name"> & {
|
||||
status: ManagedAgent["status"] | "unknown";
|
||||
agentSource: "managed" | "member-bot" | "relay";
|
||||
canInterruptTurn: boolean;
|
||||
channelIds?: string[];
|
||||
@@ -52,7 +50,8 @@ type UseChannelAgentSessionsOptions = {
|
||||
|
||||
function relayStatusToManagedStatus(
|
||||
status: RelayAgent["status"],
|
||||
): ManagedAgent["status"] {
|
||||
): ChannelAgentSessionAgent["status"] {
|
||||
if (status === "unknown") return "unknown";
|
||||
return status === "offline" ? "stopped" : "deployed";
|
||||
}
|
||||
|
||||
|
||||
@@ -130,3 +130,22 @@ test("global search results join only while global search is enabled", () => {
|
||||
assert.equal(searched[0].displayName, "Dana");
|
||||
assert.equal(searched[0].isGlobalSearchResult, true);
|
||||
});
|
||||
|
||||
test("policy-only discovery stays selectable without claiming active presence", () => {
|
||||
const [candidate] = buildMentionCandidates(
|
||||
input({
|
||||
mentionableAgentPubkeys: new Set([AGENT_PUBKEY]),
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: AGENT_PUBKEY,
|
||||
name: "Scout",
|
||||
ownerPubkey: MEMBER_PUBKEY,
|
||||
status: "unknown",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(candidate.pubkey, AGENT_PUBKEY);
|
||||
assert.equal(candidate.isActiveAgent, false);
|
||||
assert.equal(candidate.ownerPubkey, MEMBER_PUBKEY);
|
||||
});
|
||||
|
||||
@@ -181,7 +181,7 @@ export function buildMentionCandidates({
|
||||
(activePersonaById.has(pubkey) ? pubkey : undefined),
|
||||
ownerPubkey: agent.ownerPubkey,
|
||||
isAgent: true,
|
||||
isActiveAgent: agent.status !== "offline",
|
||||
isActiveAgent: agent.status === "online" || agent.status === "away",
|
||||
});
|
||||
}
|
||||
for (const agent of managedAgents ?? []) {
|
||||
|
||||
@@ -16,7 +16,9 @@ export function useActiveAgentPubkeys(
|
||||
)
|
||||
.map((agent) => normalizePubkey(agent.pubkey)),
|
||||
...(relayAgents ?? [])
|
||||
.filter((agent) => agent.status !== "offline")
|
||||
.filter(
|
||||
(agent) => agent.status === "online" || agent.status === "away",
|
||||
)
|
||||
.map((agent) => normalizePubkey(agent.pubkey)),
|
||||
]),
|
||||
[managedAgents, relayAgents],
|
||||
|
||||
@@ -178,7 +178,7 @@ export function useNewMessageRecipients({
|
||||
displayName: agent.name,
|
||||
avatarUrl: null,
|
||||
nip05Handle: null,
|
||||
ownerPubkey: null,
|
||||
ownerPubkey: agent.ownerPubkey,
|
||||
isAgent: true,
|
||||
},
|
||||
{ includeSelected: deferredSearchQuery.length > 0 },
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { resolveProfileActivityAgent } from "./profileActivityAgent.ts";
|
||||
|
||||
const input = {
|
||||
effectivePubkey: "a".repeat(64),
|
||||
isBot: true,
|
||||
managedAgent: undefined,
|
||||
profile: { displayName: "Scout" },
|
||||
relayAgent: undefined,
|
||||
viewerIsOwner: true,
|
||||
};
|
||||
|
||||
for (const [status, expected] of [
|
||||
["unknown", "unknown"],
|
||||
["online", "deployed"],
|
||||
["away", "deployed"],
|
||||
["offline", "stopped"],
|
||||
]) {
|
||||
test(`profile activity preserves relay ${status} evidence`, () => {
|
||||
const agent = resolveProfileActivityAgent({
|
||||
...input,
|
||||
relayAgent: { status, name: "Relay Scout" },
|
||||
});
|
||||
assert.equal(agent.status, expected);
|
||||
assert.equal(agent.pubkey, input.effectivePubkey);
|
||||
});
|
||||
}
|
||||
|
||||
test("profile activity cannot infer liveness from ownership alone", () => {
|
||||
assert.equal(resolveProfileActivityAgent(input).status, "unknown");
|
||||
assert.equal(
|
||||
resolveProfileActivityAgent({ ...input, viewerIsOwner: false }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("local managed runtime status still takes precedence", () => {
|
||||
assert.equal(
|
||||
resolveProfileActivityAgent({
|
||||
...input,
|
||||
managedAgent: {
|
||||
pubkey: input.effectivePubkey,
|
||||
name: "Local Scout",
|
||||
status: "running",
|
||||
},
|
||||
relayAgent: { status: "unknown" },
|
||||
}).status,
|
||||
"running",
|
||||
);
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
|
||||
|
||||
export type ProfileActivityAgent = Pick<
|
||||
ManagedAgent,
|
||||
"pubkey" | "name" | "status"
|
||||
> & {
|
||||
export type ProfileActivityAgent = Pick<ManagedAgent, "pubkey" | "name"> & {
|
||||
status: ManagedAgent["status"] | "unknown";
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
|
||||
@@ -39,6 +37,11 @@ export function resolveProfileActivityAgent({
|
||||
avatarUrl: profile?.avatarUrl ?? null,
|
||||
name: relayAgent?.name ?? profile?.displayName?.trim() ?? "Agent",
|
||||
pubkey: effectivePubkey,
|
||||
status: relayAgent?.status === "offline" ? "stopped" : "deployed",
|
||||
status:
|
||||
!relayAgent || relayAgent.status === "unknown"
|
||||
? "unknown"
|
||||
: relayAgent.status === "offline"
|
||||
? "stopped"
|
||||
: "deployed",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, beforeEach, test } from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import {
|
||||
clearActiveTurnsForAgent,
|
||||
getActiveTurnsForAgent,
|
||||
resetActiveAgentTurnsStore,
|
||||
syncAgentTurnsFromEvents,
|
||||
useActiveAgentTurns,
|
||||
} from "@/features/agents/activeAgentTurnsStore.ts";
|
||||
import {
|
||||
getAgentObserverSnapshot,
|
||||
getAgentTranscript,
|
||||
resetAgentObserverStore,
|
||||
syncAgentObserverEvents,
|
||||
} from "@/features/agents/observerRelayStore.ts";
|
||||
import {
|
||||
useAgentTranscript,
|
||||
useObserverEvents,
|
||||
} from "@/features/agents/ui/useObserverEvents.ts";
|
||||
import { resolveProfileActivityAgent } from "./profileActivityAgent.ts";
|
||||
import { useProfileActivityFeedScope } from "./profileActivityFeedScope.ts";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
window: dom.window,
|
||||
});
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const AGENT = "a".repeat(64);
|
||||
const OTHER_AGENT = "b".repeat(64);
|
||||
const nativeCalls = [];
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: async (...args) => {
|
||||
nativeCalls.push(args);
|
||||
throw new Error(
|
||||
"Reading stored profile history must not invoke native APIs",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAgentObserverStore();
|
||||
resetActiveAgentTurnsStore();
|
||||
nativeCalls.length = 0;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetAgentObserverStore();
|
||||
resetActiveAgentTurnsStore();
|
||||
assert.deepEqual(
|
||||
nativeCalls,
|
||||
[],
|
||||
"history reads must not start subscriptions",
|
||||
);
|
||||
});
|
||||
after(() => dom.window.close());
|
||||
|
||||
function resolveAgent(relayStatus, overrides = {}) {
|
||||
return resolveProfileActivityAgent({
|
||||
effectivePubkey: AGENT,
|
||||
isBot: true,
|
||||
managedAgent: undefined,
|
||||
profile: { displayName: "Scout" },
|
||||
relayAgent: relayStatus
|
||||
? { name: "Scout", status: relayStatus }
|
||||
: undefined,
|
||||
viewerIsOwner: true,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function event(seq, kind, channelId = "general", payload = {}) {
|
||||
return {
|
||||
seq,
|
||||
timestamp: new Date(Date.UTC(2026, 8, 2) + seq * 1000).toISOString(),
|
||||
kind,
|
||||
agentIndex: 0,
|
||||
channelId,
|
||||
sessionId: "session",
|
||||
turnId: "turn",
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function message(seq, channelId) {
|
||||
return event(seq, "acp_read", channelId, {
|
||||
method: "session/update",
|
||||
params: {
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "Completed the review." },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useScope(agent) {
|
||||
const turns = useActiveAgentTurns(agent?.pubkey);
|
||||
return useProfileActivityFeedScope(agent, turns);
|
||||
}
|
||||
|
||||
const idleScope = {
|
||||
channelIds: [],
|
||||
hasFeedContent: false,
|
||||
isLive: false,
|
||||
latestActivityAtByChannel: {},
|
||||
preferredChannelId: null,
|
||||
};
|
||||
|
||||
for (const [evidence, agent] of [
|
||||
["unknown", resolveAgent("unknown")],
|
||||
["absent", resolveAgent(undefined)],
|
||||
["offline", resolveAgent("offline")],
|
||||
["online", resolveAgent("online")],
|
||||
[
|
||||
"local stopped",
|
||||
resolveAgent("unknown", {
|
||||
managedAgent: { pubkey: AGENT, name: "Local Scout", status: "stopped" },
|
||||
}),
|
||||
],
|
||||
]) {
|
||||
test(`${evidence} evidence retains completed raw history with no active turns`, () => {
|
||||
const completed = event(1, "turn_completed");
|
||||
syncAgentObserverEvents(AGENT, [completed]);
|
||||
syncAgentTurnsFromEvents(AGENT, [completed]);
|
||||
assert.deepEqual(getActiveTurnsForAgent(AGENT), []);
|
||||
assert.deepEqual(getAgentTranscript(AGENT), []);
|
||||
|
||||
const { result } = renderHook(() => useScope(agent));
|
||||
assert.deepEqual(result.current, {
|
||||
channelIds: ["general"],
|
||||
hasFeedContent: true,
|
||||
isLive: false,
|
||||
latestActivityAtByChannel: { general: Date.parse(completed.timestamp) },
|
||||
preferredChannelId: "general",
|
||||
});
|
||||
if (evidence === "unknown" || evidence === "absent") {
|
||||
assert.equal(agent.status, "unknown", "history is not liveness evidence");
|
||||
}
|
||||
});
|
||||
|
||||
test(`${evidence} evidence reads stored transcript as well as raw events`, () => {
|
||||
// The last raw event has no transcript row. The existing scope contract
|
||||
// prefers the last transcript channel; dropping either read is detectable.
|
||||
syncAgentObserverEvents(AGENT, [
|
||||
message(1, "general"),
|
||||
event(2, "turn_completed", "general"),
|
||||
event(3, "turn_completed", "random"),
|
||||
]);
|
||||
const transcript = getAgentTranscript(AGENT);
|
||||
assert.equal(transcript.length, 1);
|
||||
assert.equal(transcript[0].channelId, "general");
|
||||
const { result } = renderHook(() => useScope(agent));
|
||||
assert.deepEqual(result.current.channelIds, ["general", "random"]);
|
||||
assert.equal(result.current.preferredChannelId, "general");
|
||||
assert.equal(result.current.hasFeedContent, true);
|
||||
assert.equal(result.current.isLive, false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const evidence of ["unknown", undefined]) {
|
||||
for (const finish of ["completion", "clear"]) {
|
||||
test(`${evidence ?? "absent"} evidence keeps history after active turn ${finish}`, () => {
|
||||
const agent = resolveAgent(evidence);
|
||||
const started = event(1, "turn_started");
|
||||
syncAgentObserverEvents(AGENT, [started, message(2, "general")]);
|
||||
syncAgentTurnsFromEvents(AGENT, [started]);
|
||||
const { result } = renderHook(() => useScope(agent));
|
||||
assert.equal(result.current.isLive, true);
|
||||
assert.deepEqual(result.current.channelIds, ["general"]);
|
||||
|
||||
act(() => {
|
||||
const completed = event(3, "turn_completed");
|
||||
syncAgentObserverEvents(AGENT, [completed]);
|
||||
if (finish === "clear") clearActiveTurnsForAgent(AGENT);
|
||||
else syncAgentTurnsFromEvents(AGENT, [completed]);
|
||||
});
|
||||
assert.deepEqual(getActiveTurnsForAgent(AGENT), []);
|
||||
assert.equal(result.current.isLive, false);
|
||||
assert.equal(result.current.hasFeedContent, true);
|
||||
assert.deepEqual(result.current.channelIds, ["general"]);
|
||||
assert.equal(result.current.preferredChannelId, "general");
|
||||
assert.equal(getAgentObserverSnapshot(AGENT).events.length, 3);
|
||||
assert.equal(getAgentTranscript(AGENT).length, 2);
|
||||
assert.equal(agent.status, "unknown");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("store updates, evidence loss, agent switches and reset preserve history boundaries", () => {
|
||||
const { result, rerender } = renderHook(({ agent }) => useScope(agent), {
|
||||
initialProps: { agent: resolveAgent("online") },
|
||||
});
|
||||
assert.deepEqual(result.current, idleScope);
|
||||
act(() => syncAgentObserverEvents(AGENT, [message(1, "general")]));
|
||||
assert.equal(result.current.hasFeedContent, true);
|
||||
|
||||
for (const evidence of ["unknown", undefined, "offline"]) {
|
||||
rerender({ agent: resolveAgent(evidence) });
|
||||
assert.equal(result.current.hasFeedContent, true);
|
||||
assert.equal(result.current.isLive, false);
|
||||
assert.deepEqual(result.current.channelIds, ["general"]);
|
||||
}
|
||||
rerender({
|
||||
agent: resolveAgent(undefined, { effectivePubkey: OTHER_AGENT }),
|
||||
});
|
||||
assert.deepEqual(result.current, idleScope);
|
||||
rerender({ agent: resolveAgent(undefined) });
|
||||
assert.equal(result.current.hasFeedContent, true);
|
||||
act(() => resetAgentObserverStore());
|
||||
assert.deepEqual(result.current, idleScope, "community reset hides old data");
|
||||
});
|
||||
|
||||
test("unowned, non-bot and missing identities cannot acquire stored history", () => {
|
||||
syncAgentObserverEvents(AGENT, [message(1, "general")]);
|
||||
for (const overrides of [
|
||||
{ viewerIsOwner: false },
|
||||
{ isBot: false },
|
||||
{ effectivePubkey: null },
|
||||
]) {
|
||||
const agent = resolveAgent("unknown", overrides);
|
||||
assert.equal(agent, null);
|
||||
const { result, unmount } = renderHook(() => useScope(agent));
|
||||
assert.deepEqual(result.current, idleScope);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
test("idle session readers and profile scope share history without a live subscription", () => {
|
||||
syncAgentObserverEvents(AGENT, [
|
||||
message(1, "general"),
|
||||
event(2, "turn_completed"),
|
||||
]);
|
||||
const { result } = renderHook(() => ({
|
||||
scope: useScope(resolveAgent("unknown")),
|
||||
observer: useObserverEvents(false, AGENT),
|
||||
transcript: useAgentTranscript(false, AGENT),
|
||||
}));
|
||||
assert.equal(result.current.scope.hasFeedContent, true);
|
||||
assert.equal(result.current.scope.isLive, false);
|
||||
assert.equal(result.current.observer.events.length, 2);
|
||||
assert.equal(result.current.transcript.length, 1);
|
||||
assert.equal(result.current.observer.connectionState, "idle");
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import * as React from "react";
|
||||
|
||||
import type { ActiveTurnSummary } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { subscribeActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import {
|
||||
getAgentObserverSnapshot,
|
||||
getAgentTranscript,
|
||||
@@ -231,11 +230,8 @@ export function useProfileActivityFeedScope(
|
||||
const agentCacheKey = activityAgent
|
||||
? normalizePubkey(activityAgent.pubkey)
|
||||
: "none";
|
||||
const hasObserver =
|
||||
activityAgent !== null && isManagedAgentActive(activityAgent);
|
||||
|
||||
const getSnapshot = React.useCallback(() => {
|
||||
if (!activityAgent || !hasObserver) {
|
||||
if (!activityAgent) {
|
||||
return stableFeedScope(
|
||||
agentCacheKey,
|
||||
deriveProfileActivityFeedScope({
|
||||
@@ -246,13 +242,15 @@ export function useProfileActivityFeedScope(
|
||||
);
|
||||
}
|
||||
|
||||
const { events } = getAgentObserverSnapshot(activityAgent.pubkey, true);
|
||||
const transcript = getAgentTranscript(activityAgent.pubkey, true);
|
||||
// Availability gates live subscriptions, not already-observed history.
|
||||
// Unknown or stopped agents can still have completed activity to show.
|
||||
const { events } = getAgentObserverSnapshot(activityAgent.pubkey);
|
||||
const transcript = getAgentTranscript(activityAgent.pubkey);
|
||||
return stableFeedScope(
|
||||
agentCacheKey,
|
||||
deriveProfileActivityFeedScope({ activeTurns, events, transcript }),
|
||||
);
|
||||
}, [activeTurns, activityAgent, agentCacheKey, hasObserver]);
|
||||
}, [activeTurns, activityAgent, agentCacheKey]);
|
||||
|
||||
const snapshot = React.useSyncExternalStore((onStoreChange) => {
|
||||
const unsubscribeObserver = subscribeAgentObserverStore(onStoreChange);
|
||||
|
||||
@@ -194,7 +194,7 @@ export async function submitProjectAgentMessage<Ch extends { id: string }>({
|
||||
openDm,
|
||||
send,
|
||||
}: {
|
||||
agent: { pubkey: string; isManaged: boolean; isActive: boolean };
|
||||
agent: { pubkey: string; isManaged: boolean; isActive: boolean | null };
|
||||
conversation: { channel: Ch; opener: ProjectsConversationOpener } | null;
|
||||
content: string;
|
||||
mentionPubkeys: string[];
|
||||
|
||||
@@ -90,7 +90,7 @@ export type AgentCandidate = {
|
||||
personaId?: string | null;
|
||||
/** Managed agents can be auto-started before the prompt is sent. */
|
||||
isManaged: boolean;
|
||||
isActive: boolean;
|
||||
isActive: boolean | null;
|
||||
};
|
||||
|
||||
type ProjectAgentConversation = {
|
||||
@@ -191,12 +191,18 @@ export function useAgentCandidates() {
|
||||
pubkey,
|
||||
name: agent.name,
|
||||
isManaged: false,
|
||||
isActive: agent.status !== "offline",
|
||||
isActive:
|
||||
agent.status === "unknown" ? null : agent.status !== "offline",
|
||||
});
|
||||
}
|
||||
|
||||
return candidates.sort((left, right) => {
|
||||
if (left.isActive !== right.isActive) return left.isActive ? -1 : 1;
|
||||
// Unknown is neither offline nor proof that the agent can answer now.
|
||||
const activityRank = (active: boolean | null) =>
|
||||
active === true ? 0 : active === null ? 1 : 2;
|
||||
const activityOrder =
|
||||
activityRank(left.isActive) - activityRank(right.isActive);
|
||||
if (activityOrder) return activityOrder;
|
||||
if (left.isManaged !== right.isManaged) return left.isManaged ? -1 : 1;
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
@@ -704,14 +710,16 @@ export function ProjectsAgentPromptPage({
|
||||
<span className="min-w-0 truncate">
|
||||
{candidate.name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 h-1.5 w-1.5 shrink-0 rounded-full",
|
||||
candidate.isActive
|
||||
? "bg-emerald-500"
|
||||
: "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
{candidate.isActive !== null ? (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 h-1.5 w-1.5 shrink-0 rounded-full",
|
||||
candidate.isActive
|
||||
? "bg-emerald-500"
|
||||
: "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
65
desktop/src/features/pulse/ui/AgentActivityCard.test.mjs
Normal file
65
desktop/src/features/pulse/ui/AgentActivityCard.test.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import {
|
||||
RouterContextProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createMemoryHistory,
|
||||
} from "@tanstack/react-router";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { AgentActivityCard } from "./AgentActivityCard.tsx";
|
||||
|
||||
function render(status) {
|
||||
const pubkey = "a".repeat(64);
|
||||
const router = createRouter({
|
||||
routeTree: createRootRoute(),
|
||||
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
RouterContextProvider,
|
||||
{ router },
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{
|
||||
client: new QueryClient({
|
||||
defaultOptions: { queries: { enabled: false } },
|
||||
}),
|
||||
},
|
||||
React.createElement(AgentActivityCard, {
|
||||
agentStatus: status,
|
||||
profile: { displayName: "Policy-only Scout", avatarUrl: null },
|
||||
group: {
|
||||
pubkey,
|
||||
latestAt: 1700000000,
|
||||
earliestAt: 1700000000,
|
||||
notes: [
|
||||
{
|
||||
id: "note",
|
||||
pubkey,
|
||||
content: "Still discoverable",
|
||||
createdAt: 1700000000,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test("Pulse does not render unknown policy-only discovery as an offline dot", () => {
|
||||
const html = render("unknown");
|
||||
assert.match(html, /Policy-only Scout/);
|
||||
assert.match(html, /Still discoverable/);
|
||||
assert.doesNotMatch(html, /aria-label="Agent (offline|online|away)"/);
|
||||
assert.doesNotMatch(html, /bg-zinc-400/);
|
||||
});
|
||||
|
||||
for (const status of ["online", "away", "offline"]) {
|
||||
test(`Pulse retains explicit ${status} liveness evidence`, () => {
|
||||
assert.match(render(status), new RegExp(`aria-label="Agent ${status}"`));
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
type AgentActivityCardProps = {
|
||||
group: AgentNoteGroup;
|
||||
profile?: UserProfileSummary | null;
|
||||
agentStatus?: "online" | "away" | "offline";
|
||||
agentStatus?: "online" | "away" | "offline" | "unknown";
|
||||
};
|
||||
|
||||
function formatRelativeTime(unixSeconds: number): string {
|
||||
@@ -36,7 +36,13 @@ function StatusDot({ status }: { status: "online" | "away" | "offline" }) {
|
||||
: status === "away"
|
||||
? "bg-amber-500"
|
||||
: "bg-zinc-400";
|
||||
return <span className={`inline-block h-2 w-2 rounded-full ${color}`} />;
|
||||
return (
|
||||
<span
|
||||
aria-label={`Agent ${status}`}
|
||||
role="img"
|
||||
className={`inline-block h-2 w-2 rounded-full ${color}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentActivityCard({
|
||||
@@ -79,7 +85,9 @@ export function AgentActivityCard({
|
||||
<span className="truncate text-sm font-semibold leading-none">
|
||||
{displayName}
|
||||
</span>
|
||||
{agentStatus ? <StatusDot status={agentStatus} /> : null}
|
||||
{agentStatus && agentStatus !== "unknown" ? (
|
||||
<StatusDot status={agentStatus} />
|
||||
) : null}
|
||||
<span className="shrink-0 text-2xs text-muted-foreground">
|
||||
{formatRelativeTime(group.latestAt)}
|
||||
</span>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
||||
[agentPubkeys],
|
||||
);
|
||||
const agentStatusMap = React.useMemo(() => {
|
||||
const map: Record<string, "online" | "away" | "offline"> = {};
|
||||
const map: Record<string, "online" | "away" | "offline" | "unknown"> = {};
|
||||
for (const a of relayAgents) {
|
||||
map[a.pubkey] = a.status;
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ export function useSearchResults({
|
||||
displayName: agent.name,
|
||||
avatarUrl: null,
|
||||
nip05Handle: null,
|
||||
ownerPubkey: null,
|
||||
ownerPubkey: agent.ownerPubkey,
|
||||
isAgent: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -270,7 +270,8 @@ export type RelayAgent = {
|
||||
channels: string[];
|
||||
channelIds: string[];
|
||||
capabilities: string[];
|
||||
status: "online" | "away" | "offline";
|
||||
/** Policy-only discovery has no liveness evidence. */
|
||||
status: "online" | "away" | "offline" | "unknown";
|
||||
respondTo: RespondToMode | null;
|
||||
respondToAllowlist: string[];
|
||||
};
|
||||
|
||||
@@ -922,7 +922,7 @@ type RawRelayAgent = {
|
||||
channels: string[];
|
||||
channel_ids: string[];
|
||||
capabilities: string[];
|
||||
status: PresenceStatus;
|
||||
status: PresenceStatus | "unknown";
|
||||
respond_to?: "owner-only" | "allowlist" | "anyone";
|
||||
respond_to_allowlist?: string[];
|
||||
};
|
||||
|
||||
56
desktop/tests/e2e/owned-agent-discovery.spec.ts
Normal file
56
desktop/tests/e2e/owned-agent-discovery.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installMockBridge, openNewMessagePage } from "../helpers/bridge";
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
|
||||
const AGENT = "a7".repeat(32);
|
||||
const OWNER = "deadbeef".repeat(8);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [],
|
||||
searchProfiles: [],
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: AGENT,
|
||||
name: "Policy-only Scout",
|
||||
ownerPubkey: OWNER,
|
||||
status: "unknown",
|
||||
respondTo: "owner-only",
|
||||
channelNames: [],
|
||||
channelIds: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("New Message keeps authenticated owner without a user-search duplicate", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openNewMessagePage(page);
|
||||
await page.getByTestId("new-dm-search").fill("Policy-only Scout");
|
||||
const row = page.getByTestId(`new-dm-result-${AGENT}`);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText("managed by you");
|
||||
await waitForAnimations(page);
|
||||
await row.screenshot({
|
||||
path: "test-results/owned-agent-discovery/new-message-owner.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("member-add keeps authenticated owner without a user-search duplicate", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.getByTestId("channel-members-trigger").click();
|
||||
await page
|
||||
.getByTestId("channel-management-search-users")
|
||||
.fill("Policy-only Scout");
|
||||
const row = page.getByTestId(`channel-user-search-result-${AGENT}`);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText("managed by you");
|
||||
await waitForAnimations(page);
|
||||
await row.screenshot({
|
||||
path: "test-results/owned-agent-discovery/member-add-owner.png",
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ type MockSearchProfileSeed = {
|
||||
|
||||
type MockRelayAgentSeed = {
|
||||
pubkey: string;
|
||||
ownerPubkey?: string | null;
|
||||
name: string;
|
||||
agentType?: string;
|
||||
capabilities?: string[];
|
||||
@@ -63,7 +64,7 @@ type MockRelayAgentSeed = {
|
||||
respondToAllowlist?: string[];
|
||||
channelNames?: string[];
|
||||
channelIds?: string[];
|
||||
status?: "online" | "away" | "offline";
|
||||
status?: "online" | "away" | "offline" | "unknown";
|
||||
};
|
||||
|
||||
type MockHuddleSeed = {
|
||||
|
||||
58
docs/owned-agent-discovery.md
Normal file
58
docs/owned-agent-discovery.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Authenticated owned-agent discovery
|
||||
|
||||
An owner-authored kind 30177 coordinate seeds discovery independently of local
|
||||
runtime inventory and shared-channel membership. It is not ownership proof. The
|
||||
latest agent kind 0 profile must have a valid envelope and exactly one valid
|
||||
NIP-OA auth tag. Every signed condition is evaluated against event time, never
|
||||
wall-clock time. The owner remains provenance, not the agent's author identity.
|
||||
|
||||
Owner policy coordinates must match that verified owner and have valid signed
|
||||
envelopes. Invalid latest policy reserves the coordinate and fails closed: it
|
||||
cannot revive a legacy permission. Missing policy retains existing OSS legacy
|
||||
compatibility; marked builds require verified owner policy. This change does not
|
||||
invent a missing-policy default or broaden policy audiences.
|
||||
|
||||
Membership comes from the latest relay-signed kind 39002 snapshot for each
|
||||
channel. Known owned agents need not carry the cosmetic bot role. Ownership does
|
||||
not fabricate membership: a nonmember may be discovered with empty channel_ids.
|
||||
Selection queries constrain exact requested keys and destination. Relay authority
|
||||
is obtained through the existing community-bound relay admission boundary.
|
||||
|
||||
This native change expands discovery only. Preparing an invitation and freshly
|
||||
authorizing publication to its exact destination belong to the subsequent
|
||||
mention-routing change; discovery alone does not authorize a message.
|
||||
|
||||
## Regression gates
|
||||
|
||||
- `commands/agent_discovery/relay_directory/owned_tests.rs`: signed loopback
|
||||
discovery with no local record, forged ownership exclusion, ordinary-role
|
||||
membership, wrong destination, revocation and unsupported latest policy.
|
||||
- `nostr_convert/oa_profile_tests.rs`: signed envelope, duplicate/malformed auth,
|
||||
canonical conditions and signed-event time, wrong-owner policy, invalid latest
|
||||
policy, forged and stale membership.
|
||||
- `nostr_convert/tests.rs`: signed conversion fixtures and OSS compatibility.
|
||||
- `nostr_convert/runtime_policy_tests.rs`: known signed runtime status survives
|
||||
policy overlay; absent/unrecognized status, policy-only discovery, and an
|
||||
invalid or status-less latest runtime cannot fabricate or revive availability.
|
||||
|
||||
NIP-OA time conditions are not wall-clock expiry (see `docs/nips/NIP-OA.md`). Relay
|
||||
membership and policy reads remain separate snapshots, not an atomic publication
|
||||
transaction. Independent security review is required before landing.
|
||||
|
||||
## Presentation preserves evidence
|
||||
|
||||
Policy-only agents carry `status: "unknown"` through IPC. When the same agent has
|
||||
an independently verified latest kind 10100 runtime record, its explicit
|
||||
`online`, `away`, or `offline` status survives the owner-policy overlay. Missing
|
||||
or unrecognized runtime status remains unknown, not the generic converter's
|
||||
legacy offline default. Policy still controls ownership and permissions; claimed
|
||||
runtime membership is not restored. An invalid latest policy still excludes the
|
||||
agent rather than falling back to runtime permissions.
|
||||
|
||||
Discovery alone supplies no liveness evidence: Pulse and Projects omit the status
|
||||
dot rather than inventing offline or active presence, and active-agent lookups
|
||||
require positive evidence.
|
||||
Channel and profile activity projections preserve unknown rather than claiming a
|
||||
deployed observer; local managed runtime status still takes precedence.
|
||||
Authenticated `ownerPubkey` is retained by New Message, member-add, and search
|
||||
candidates even when no user-search profile duplicates the relay result.
|
||||
Reference in New Issue
Block a user