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.


![new-message-owner](https://raw.githubusercontent.com/block/buzz/536c6d3c90776cf85b1d5ce58666f2c8ad518829/pr-7122--new-message-owner.png)

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


![member-add-owner](https://raw.githubusercontent.com/block/buzz/536c6d3c90776cf85b1d5ce58666f2c8ad518829/pr-7122--member-add-owner.png)

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-09-02 16:18:54 -04:00
committed by GitHub
parent 0dbd036f5b
commit 434dafe958
32 changed files with 1363 additions and 87 deletions

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View 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);
}

View 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");
}

View File

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

View File

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

View File

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

View File

@@ -332,7 +332,7 @@ export function MembersSidebar({
displayName: agent.name,
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
ownerPubkey: agent.ownerPubkey,
isAgent: true,
});
}

View File

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

View File

@@ -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";
}

View File

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

View File

@@ -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 ?? []) {

View File

@@ -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],

View File

@@ -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 },

View File

@@ -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",
);
});

View File

@@ -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",
};
}

View File

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

View File

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

View File

@@ -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[];

View File

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

View 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}"`));
});
}

View File

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

View File

@@ -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;
}

View File

@@ -392,7 +392,7 @@ export function useSearchResults({
displayName: agent.name,
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
ownerPubkey: agent.ownerPubkey,
isAgent: true,
};

View File

@@ -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[];
};

View File

@@ -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[];
};

View 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",
});
});

View File

@@ -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 = {

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