Proof-of-concept mesh agent library over iroh
Working library with real iroh connections, not an interface sketch: - persistent device identity in state.sqlite, stable across restarts - deterministic network space derived from name + secret via HKDF-SHA256, with frozen labels and unambiguous length-prefixed encoding - replaceable discovery returning unverified candidates only; static bootstrap, in-memory test backend and a composite - real iroh connections plus an explicit mutual membership proof: HMAC-SHA256 over a role-separated transcript bound to the TLS exporter, the network id and both endpoint identities - small versioned control protocol: handshake, announcement, ping/pong - multiple networks per agent with enforced isolation - automatic reconnect with bounded backoff and jitter - mandatory state vs disposable cache, with a real directory ownership lock - status snapshots, event stream and honest diagnostics 47 integration and unit tests cover the required scenarios offline on loopback. Snapshots, revocations and WireGuard are designed for and documented, not implemented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
//! Scenario 2: several agents find each other, authenticate for real and
|
||||
//! exchange distinguishable messages.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::{TestAgent, network, wait_event, wait_for_peers, wait_until};
|
||||
use tsunagi::agent::Event;
|
||||
use tsunagi::dataplane::TestCapabilityPlugin;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::proto::ControlMessage;
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_agents_form_a_mesh_and_exchange_distinguishable_messages() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("mesh-of-four");
|
||||
|
||||
let mut agents = Vec::new();
|
||||
for index in 0..4 {
|
||||
let hostname = format!("host-{index}");
|
||||
agents.push(
|
||||
TestAgent::spawn_with(move |cfg| cfg.with_hostname(hostname), &discovery)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut network_ids = HashSet::new();
|
||||
for agent in &agents {
|
||||
network_ids.insert(agent.agent.join_network(&name, &secret).await.unwrap());
|
||||
}
|
||||
assert_eq!(network_ids.len(), 1, "one deterministic network space");
|
||||
let network_id = network_ids.into_iter().next().unwrap();
|
||||
|
||||
// Full mesh: every agent must end up with the other three.
|
||||
for agent in &agents {
|
||||
wait_for_peers(&agent.agent, network_id, 3).await;
|
||||
}
|
||||
|
||||
// Each peer announced its own hostname, so sessions are distinguishable.
|
||||
let status = agents[0].agent.network_status(network_id).await.unwrap();
|
||||
let hostnames: HashSet<String> = status
|
||||
.peers
|
||||
.iter()
|
||||
.filter_map(|peer| peer.hostname.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
hostnames.len(),
|
||||
3,
|
||||
"three distinct hostnames: {hostnames:?}"
|
||||
);
|
||||
|
||||
// Distinguishable request/response: each peer echoes its own sequence.
|
||||
let mut events = agents[0].agent.subscribe();
|
||||
for (index, peer) in agents.iter().skip(1).enumerate() {
|
||||
agents[0]
|
||||
.agent
|
||||
.send(
|
||||
network_id,
|
||||
peer.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: index as u64 + 1,
|
||||
payload: format!("to-{index}").into_bytes(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
while seen.len() < 3 {
|
||||
let (peer, seq, payload) = wait_event(&mut events, |event| match event {
|
||||
Event::MessageReceived {
|
||||
network,
|
||||
peer,
|
||||
message: ControlMessage::Pong { seq, payload },
|
||||
} if *network == network_id => Some((*peer, *seq, payload.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(payload, format!("to-{}", seq - 1).into_bytes());
|
||||
seen.insert(peer);
|
||||
}
|
||||
assert_eq!(seen.len(), 3);
|
||||
|
||||
for agent in agents {
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_late_joiner_is_picked_up_by_the_existing_members() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("late-joiner");
|
||||
|
||||
let first = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = first.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// Nobody else is there yet. That is "nobody found so far", not proof that
|
||||
// the network is empty, and the agent is ready regardless.
|
||||
let status = first.agent.network_status(network_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
|
||||
let second = TestAgent::spawn(&discovery).await.unwrap();
|
||||
second.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
wait_for_peers(&first.agent, network_id, 1).await;
|
||||
wait_for_peers(&second.agent, network_id, 1).await;
|
||||
|
||||
first.agent.shutdown().await;
|
||||
second.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opaque_plugin_capabilities_cross_the_control_plane() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("capabilities");
|
||||
|
||||
// An explicitly test-only capability: nothing here advertises WireGuard.
|
||||
let plugin_a = Arc::new(TestCapabilityPlugin::new("test-ip", b"payload-a".to_vec()));
|
||||
let plugin_b = Arc::new(TestCapabilityPlugin::new("test-ip", b"payload-b".to_vec()));
|
||||
|
||||
let a = TestAgent::spawn_with(
|
||||
{
|
||||
let plugin = Arc::clone(&plugin_a);
|
||||
move |cfg| cfg.with_plugin(plugin)
|
||||
},
|
||||
&discovery,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let b = TestAgent::spawn_with(
|
||||
{
|
||||
let plugin = Arc::clone(&plugin_b);
|
||||
move |cfg| cfg.with_plugin(plugin)
|
||||
},
|
||||
&discovery,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&a.agent, network_id, 1).await;
|
||||
|
||||
let observed = wait_until("plugin a sees b's capability", || {
|
||||
let plugin = Arc::clone(&plugin_a);
|
||||
async move {
|
||||
let seen = plugin.observed();
|
||||
if seen.is_empty() { None } else { Some(seen) }
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let (seen_network, seen_peer, capability) = &observed[0];
|
||||
assert_eq!(*seen_network, network_id);
|
||||
assert_eq!(*seen_peer, b.agent.endpoint_id());
|
||||
assert_eq!(capability.protocol, "test-ip");
|
||||
assert_eq!(capability.data, b"payload-b".to_vec());
|
||||
assert!(capability.enabled);
|
||||
|
||||
// The core carried the payload without interpreting it.
|
||||
let status = a.agent.network_status(network_id).await.unwrap();
|
||||
assert_eq!(status.peers[0].capabilities[0].data, b"payload-b".to_vec());
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
Reference in New Issue
Block a user