For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Tunnel NATS leaf node connections over iroh QUIC streams so P2P agents get presence and messaging without central NATS infrastructure.
Architecture: The iroh sidecar gains a TCP-to-QUIC tunnel on a new ALPN. Each Go agent runs an embedded NATS server. A NATSMesh module owns the lifecycle: start embedded NATS, start sidecar with --nats-addr, establish tunnels to peers based on role and EndpointId comparison. The agent connects its NATS client to the local embedded server.
Tech Stack: Rust (iroh-sidecar, tokio, axum), Go (nats-server embedded, leaf agent), iroh 0.97
Target repos: EdgeSync (leaf/agent/, iroh-sidecar/src/)
Spec: docs/superpowers/specs/2026-04-08-nats-over-iroh-design.md
IMPORTANT: Do not add any Claude Code co-author attribution to commits. Work on a feature branch, never push directly to main.
File Structure
Rust (iroh-sidecar)
| Action | File | Responsibility |
|---|---|---|
| Create | iroh-sidecar/src/tunnel.rs |
Bidirectional TCP-QUIC pipe for NATS leaf connections |
| Modify | iroh-sidecar/src/acceptor.rs |
ALPN-based dispatch (xfer vs NATS tunnel) |
| Modify | iroh-sidecar/src/server.rs |
New endpoint POST /nats-tunnel/{endpoint_id} |
| Modify | iroh-sidecar/src/main.rs |
New CLI arg --nats-addr, register second ALPN, mod tunnel |
| Modify | iroh-sidecar/src/endpoint.rs |
Accept multiple ALPNs |
Go (leaf agent)
| Action | File | Responsibility |
|---|---|---|
| Create | leaf/agent/nats_mesh.go |
Embedded NATS + tunnel establishment + startup sequencing |
| Create | leaf/agent/nats_mesh_test.go |
Unit tests for mesh lifecycle and role logic |
| Modify | leaf/agent/config.go |
Add NATSRole, rename NATSUrl → NATSUpstream |
| Modify | leaf/agent/agent.go |
Use NATSMesh in lifecycle, simplify syncTargets |
| Modify | leaf/agent/sidecar.go |
Accept optional --nats-addr arg |
Task 1: Sidecar — TCP tunnel module
Files:
Create:
iroh-sidecar/src/tunnel.rs[ ] Step 1: Create the tunnel module
Create iroh-sidecar/src/tunnel.rs:
//! Bidirectional TCP ↔ QUIC tunnel for NATS leaf node connections.
//!
//! When a remote peer connects on ALPN `/edgesync/nats-leaf/1`, we:
//! 1. Accept the bidirectional QUIC stream.
//! 2. Open a TCP connection to the local NATS server.
//! 3. Pipe bytes bidirectionally until either side closes.
use iroh::endpoint::Connection;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
/// Handle an incoming NATS tunnel connection from a remote peer.
///
/// Opens a TCP connection to the local embedded NATS at `nats_addr`,
/// then pipes bytes between the QUIC stream and the TCP socket.
pub async fn handle_connection(connection: Connection, nats_addr: String) {
let remote_id = connection.remote_id();
tracing::info!(%remote_id, "nats tunnel: accepted connection");
loop {
let (send, recv) = match connection.accept_bi().await {
Ok(streams) => streams,
Err(e) => {
tracing::debug!(%remote_id, "nats tunnel: stream accept ended: {e}");
break;
}
};
let addr = nats_addr.clone();
tokio::spawn(async move {
if let Err(e) = pipe_stream(send, recv, &addr).await {
tracing::warn!("nats tunnel: pipe error: {e:#}");
}
});
}
}
/// Establish an outbound NATS tunnel to a remote peer.
///
/// Opens a QUIC bidirectional stream on the given connection using the
/// NATS ALPN, then pipes to the local NATS server at `nats_addr`.
pub async fn establish_outbound(connection: Connection, nats_addr: String) -> anyhow::Result<()> {
let remote_id = connection.remote_id();
tracing::info!(%remote_id, "nats tunnel: establishing outbound tunnel");
let (send, recv) = connection.open_bi().await?;
pipe_stream(send, recv, &nats_addr).await?;
Ok(())
}
async fn pipe_stream(
mut quic_send: iroh::endpoint::SendStream,
mut quic_recv: iroh::endpoint::RecvStream,
nats_addr: &str,
) -> anyhow::Result<()> {
let tcp = TcpStream::connect(nats_addr).await?;
let (mut tcp_read, mut tcp_write) = tcp.into_split();
// Bidirectional pipe: QUIC → TCP and TCP → QUIC run concurrently.
let quic_to_tcp = async {
let mut buf = vec![0u8; 32 * 1024];
loop {
let n = match quic_recv.read(&mut buf).await {
Ok(Some(n)) => n,
Ok(None) => break, // QUIC stream finished
Err(e) => return Err(anyhow::anyhow!("quic read: {e}")),
};
tcp_write.write_all(&buf[..n]).await?;
}
tcp_write.shutdown().await?;
Ok::<(), anyhow::Error>(())
};
let tcp_to_quic = async {
let mut buf = vec![0u8; 32 * 1024];
loop {
let n = tcp_read.read(&mut buf).await?;
if n == 0 {
break; // TCP closed
}
quic_send.write_all(&buf[..n]).await?;
}
quic_send.finish()?;
Ok::<(), anyhow::Error>(())
};
tokio::select! {
r = quic_to_tcp => r?,
r = tcp_to_quic => r?,
}
Ok(())
}
- [ ] Step 2: Register the module
In iroh-sidecar/src/main.rs, add at line 1:
mod acceptor;
mod endpoint;
mod server;
mod tunnel;
- [ ] Step 3: Verify it compiles
Run: cd iroh-sidecar && cargo check
Expected: Compiles (tunnel is defined but not yet called).
- [ ] Step 4: Commit
git add iroh-sidecar/src/tunnel.rs iroh-sidecar/src/main.rs
git commit -m "feat(sidecar): add TCP-QUIC tunnel module for NATS leaf connections"
Task 2: Sidecar — ALPN dispatch in acceptor
Files:
- Modify:
iroh-sidecar/src/acceptor.rs - Modify:
iroh-sidecar/src/endpoint.rs Modify:
iroh-sidecar/src/main.rs[ ] Step 1: Define ALPN constants in main.rs
Add constants and pass both ALPNs to the endpoint and acceptor. In main.rs, after the Args struct:
pub const XFER_ALPN: &[u8] = b"/edgesync/xfer/1";
pub const NATS_ALPN: &[u8] = b"/edgesync/nats-leaf/1";
- [ ] Step 2: Update endpoint.rs to accept multiple ALPNs
Change create_endpoint to accept a slice of ALPNs:
pub async fn create_endpoint(
key_path: &Path,
alpns: &[&[u8]],
) -> anyhow::Result<(Endpoint, String)> {
let secret_key = load_or_generate_key(key_path).await?;
let endpoint_id = secret_key.public().to_string();
let endpoint = Endpoint::builder(presets::N0)
.secret_key(secret_key)
.alpns(alpns.iter().map(|a| a.to_vec()).collect())
.bind()
.await?;
tracing::info!(%endpoint_id, "iroh endpoint bound");
Ok((endpoint, endpoint_id))
}
- [ ] Step 3: Update main.rs to register both ALPNs and pass nats_addr
Add --nats-addr CLI arg:
#[derive(Parser)]
#[command(name = "iroh-sidecar", about = "Iroh P2P proxy for EdgeSync")]
struct Args {
#[arg(long)]
socket: PathBuf,
#[arg(long)]
key_path: PathBuf,
#[arg(long)]
callback: String,
#[arg(long, default_value = "/edgesync/xfer/1")]
alpn: String,
/// Local NATS address to tunnel leaf node connections to.
/// If omitted, NATS tunneling is disabled.
#[arg(long)]
nats_addr: Option<String>,
}
Update the endpoint creation call:
let alpns: Vec<&[u8]> = if args.nats_addr.is_some() {
vec![args.alpn.as_bytes(), NATS_ALPN]
} else {
vec![args.alpn.as_bytes()]
};
let (ep, endpoint_id) = endpoint::create_endpoint(&args.key_path, &alpns).await?;
Pass nats_addr to the accept loop:
let nats_addr = args.nats_addr.clone();
let accept_handle = tokio::spawn(async move {
acceptor::run_accept_loop(accept_ep, callback_url, nats_addr).await;
});
- [ ] Step 4: Update acceptor.rs to dispatch by ALPN
Change run_accept_loop signature to accept nats_addr:
pub async fn run_accept_loop(
endpoint: Endpoint,
callback_url: String,
nats_addr: Option<String>,
) {
In handle_incoming, dispatch based on ALPN. The connection's ALPN is available via connection.alpn():
async fn handle_incoming(
incoming: iroh::endpoint::Incoming,
client: reqwest::Client,
callback_url: String,
nats_addr: Option<String>,
) -> anyhow::Result<()> {
let connection = incoming.accept()?.await?;
let remote_id = connection.remote_id();
let alpn = connection.alpn();
tracing::info!(%remote_id, alpn = %String::from_utf8_lossy(&alpn), "accepted incoming connection");
if alpn == crate::NATS_ALPN {
if let Some(addr) = nats_addr {
crate::tunnel::handle_connection(connection, addr).await;
} else {
tracing::warn!(%remote_id, "NATS tunnel connection received but --nats-addr not configured");
}
return Ok(());
}
// Existing xfer handling (unchanged from here down)
let mut stream_tasks = JoinSet::new();
loop {
let (mut send, mut recv) = match connection.accept_bi().await {
Ok(streams) => streams,
Err(e) => {
tracing::debug!(%remote_id, "stream accept ended: {e}");
break;
}
};
let client = client.clone();
let callback_url = callback_url.clone();
stream_tasks.spawn(async move {
if let Err(e) = handle_stream(&mut send, &mut recv, &client, &callback_url, remote_id).await {
tracing::warn!(%remote_id, "stream handler error: {e:#}");
}
});
}
while let Some(result) = stream_tasks.join_next().await {
if let Err(e) = result {
tracing::warn!(%remote_id, "stream task panicked: {e:#}");
}
}
Ok(())
}
Update the tasks.spawn call in run_accept_loop to pass nats_addr:
tasks.spawn(async move {
if let Err(e) = handle_incoming(incoming, client, callback_url, nats_addr).await {
tracing::warn!("accept loop: error handling incoming connection: {e:#}");
}
});
(Note: nats_addr needs to be cloned per iteration since it's moved into the spawned task.)
- [ ] Step 5: Verify it compiles
Run: cd iroh-sidecar && cargo check
Expected: Compiles. Existing behavior unchanged when --nats-addr is not provided.
- [ ] Step 6: Commit
git add iroh-sidecar/src/
git commit -m "feat(sidecar): ALPN dispatch — route xfer and NATS tunnel connections separately"
Task 3: Sidecar — outbound tunnel HTTP endpoint
Files:
Modify:
iroh-sidecar/src/server.rs[ ] Step 1: Add nats_addr to AppState
In server.rs, add a field to AppState:
pub struct AppState {
pub endpoint: Endpoint,
pub endpoint_id: String,
pub alpn: Vec<u8>,
pub nats_addr: Option<String>, // NEW
pub conn_cache: Arc<Mutex<HashMap<String, Connection>>>,
pub shutdown_tx: Arc<Mutex<Option<oneshot::Sender<()>>>>,
}
Update the constructor:
pub fn new(
endpoint: Endpoint,
endpoint_id: String,
alpn: Vec<u8>,
nats_addr: Option<String>,
shutdown_tx: oneshot::Sender<()>,
) -> Self {
AppState {
endpoint,
endpoint_id,
alpn,
nats_addr,
conn_cache: Arc::new(Mutex::new(HashMap::new())),
shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))),
}
}
- [ ] Step 2: Add the route and handler
Add route to build_router:
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/exchange/{endpoint_id}", post(handle_exchange))
.route("/nats-tunnel/{endpoint_id}", post(handle_nats_tunnel))
.route("/status", get(handle_status))
.route("/shutdown", post(handle_shutdown))
.with_state(state)
}
Add the handler:
/// POST /nats-tunnel/{endpoint-id}
///
/// Establish a long-lived NATS tunnel to a remote peer. Opens a QUIC
/// connection on the NATS ALPN and pipes bytes to the local NATS server.
/// Returns 200 immediately; the tunnel runs in a background task.
async fn handle_nats_tunnel(
State(state): State<AppState>,
Path(remote_id_str): Path<String>,
) -> Result<Response, AppError> {
let nats_addr = state.nats_addr.as_ref()
.ok_or_else(|| AppError::bad_request("NATS tunneling not configured (no --nats-addr)"))?
.clone();
let remote_id = EndpointId::from_str(&remote_id_str)
.map_err(|e| AppError::bad_request(format!("invalid endpoint id: {e}")))?;
// Connect to remote peer on the NATS ALPN.
let nats_alpn = crate::NATS_ALPN.to_vec();
let addr: EndpointAddr = remote_id.into();
let conn = state.endpoint.connect(addr, &nats_alpn)
.await
.map_err(AppError::internal)?;
tracing::info!(remote = %remote_id_str, "nats tunnel: established outbound connection");
// Spawn the tunnel in the background — it runs until either side closes.
tokio::spawn(async move {
if let Err(e) = crate::tunnel::establish_outbound(conn, nats_addr).await {
tracing::warn!(remote = %remote_id_str, "nats tunnel: outbound ended: {e:#}");
}
});
Ok(StatusCode::OK.into_response())
}
- [ ] Step 3: Update main.rs to pass nats_addr to AppState
In main.rs:
let state = server::AppState::new(
ep.clone(),
endpoint_id,
args.alpn.as_bytes().to_vec(),
args.nats_addr.clone(),
shutdown_tx,
);
- [ ] Step 4: Verify it compiles
Run: cd iroh-sidecar && cargo check
- [ ] Step 5: Build the sidecar
Run: cd iroh-sidecar && cargo build
Expected: Successful build.
- [ ] Step 6: Commit
git add iroh-sidecar/src/
git commit -m "feat(sidecar): add POST /nats-tunnel endpoint for outbound NATS tunnels"
Task 4: Go — Config changes
Files:
Modify:
leaf/agent/config.go[ ] Step 1: Add NATSRole and rename NATSUrl
In config.go, add the NATSRole type and field, rename NATSUrl:
// NATSRole determines how the embedded NATS server participates in the mesh.
type NATSRole string
const (
// NATSRolePeer is the default. Accepts and solicits leaf connections
// based on EndpointId comparison (lower ID solicits).
NATSRolePeer NATSRole = "peer"
// NATSRoleHub only accepts leaf connections, never solicits.
// Use for dedicated servers or cluster nodes.
NATSRoleHub NATSRole = "hub"
// NATSRoleLeaf always solicits outward, never accepts.
// Use for WASM browsers or lightweight clients.
NATSRoleLeaf NATSRole = "leaf"
)
In the Config struct, rename NATSUrl to NATSUpstream and add NATSRole:
// NATSRole determines mesh topology (default "peer").
NATSRole NATSRole
// NATSUpstream is an optional external NATS server URL.
// If set, the embedded NATS joins it as a leaf node.
// Replaces the old NATSUrl field.
NATSUpstream string
Remove or comment the old NATSUrl field. Update applyDefaults():
func (c *Config) applyDefaults() {
// NATSUrl default removed — embedded NATS replaces it.
if c.NATSRole == "" {
c.NATSRole = NATSRolePeer
}
// ... rest unchanged ...
}
Update validate() to check NATSRole:
func (c *Config) validate() error {
if c.RepoPath == "" {
return errors.New("agent: config: RepoPath is required")
}
switch c.NATSRole {
case NATSRolePeer, NATSRoleHub, NATSRoleLeaf:
// valid
default:
return fmt.Errorf("agent: config: invalid NATSRole %q (must be peer, hub, or leaf)", c.NATSRole)
}
return nil
}
- [ ] Step 2: Fix all references to NATSUrl
Search for NATSUrl across the leaf module and update to NATSUpstream. Key files: agent.go, nats.go, cmd/leaf/main.go.
- [ ] Step 3: Verify it compiles
Run: cd leaf && go build ./...
- [ ] Step 4: Commit
git add leaf/
git commit -m "feat(agent): add NATSRole config, rename NATSUrl to NATSUpstream"
Task 5: Go — NATSMesh module
Files:
- Create:
leaf/agent/nats_mesh.go Create:
leaf/agent/nats_mesh_test.go[ ] Step 1: Write the test
Create leaf/agent/nats_mesh_test.go:
package agent
import (
"testing"
"time"
natsserver "github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
)
func TestNATSMeshStartStop(t *testing.T) {
mesh := &NATSMesh{
role: NATSRolePeer,
}
clientURL, err := mesh.Start()
if err != nil {
t.Fatalf("Start: %v", err)
}
defer mesh.Stop()
if clientURL == "" {
t.Fatal("clientURL is empty")
}
// Verify we can connect and publish.
nc, err := nats.Connect(clientURL, nats.Timeout(2*time.Second))
if err != nil {
t.Fatalf("nats.Connect: %v", err)
}
defer nc.Close()
if err := nc.Publish("test.subject", []byte("hello")); err != nil {
t.Fatalf("Publish: %v", err)
}
nc.Flush()
t.Logf("mesh started at %s, publish OK", clientURL)
}
func TestNATSMeshRoleLeafPort(t *testing.T) {
// peer/hub should have a leaf node port; leaf should not.
for _, role := range []NATSRole{NATSRolePeer, NATSRoleHub, NATSRoleLeaf} {
t.Run(string(role), func(t *testing.T) {
mesh := &NATSMesh{role: role}
opts := mesh.buildServerOpts()
if role == NATSRoleLeaf {
if opts.LeafNode.Port != 0 {
t.Errorf("leaf role should not set LeafNode.Port, got %d", opts.LeafNode.Port)
}
} else {
if opts.LeafNode.Port != -1 {
t.Errorf("%s role should set LeafNode.Port to -1 (random), got %d", role, opts.LeafNode.Port)
}
}
})
}
}
func TestTunnelShouldSolicit(t *testing.T) {
tests := []struct {
role NATSRole
myID string
peerID string
expected bool
}{
{NATSRoleLeaf, "zzz", "aaa", true}, // leaf always solicits
{NATSRoleHub, "aaa", "zzz", false}, // hub never solicits
{NATSRolePeer, "aaa", "zzz", true}, // lower ID solicits
{NATSRolePeer, "zzz", "aaa", false}, // higher ID waits
{NATSRolePeer, "aaa", "aaa", false}, // same ID (shouldn't happen) — don't solicit
}
for _, tt := range tests {
name := string(tt.role) + "_" + tt.myID + "_vs_" + tt.peerID
t.Run(name, func(t *testing.T) {
got := shouldSolicit(tt.role, tt.myID, tt.peerID)
if got != tt.expected {
t.Errorf("shouldSolicit(%s, %s, %s) = %v, want %v",
tt.role, tt.myID, tt.peerID, got, tt.expected)
}
})
}
}
- [ ] Step 2: Write the implementation
Create leaf/agent/nats_mesh.go:
package agent
import (
"fmt"
"net/http"
"time"
natsserver "github.com/nats-io/nats-server/v2/server"
)
// NATSMesh owns the embedded NATS server and tunnel establishment lifecycle.
// It encapsulates startup ordering: embedded NATS → sidecar → tunnels.
type NATSMesh struct {
role NATSRole
upstream string // optional external NATS URL
irohPeers []string // remote EndpointIds
endpointID string // our iroh EndpointId (set after sidecar starts)
sidecar *sidecar // nil if iroh disabled
server *natsserver.Server
clientURL string // nats://127.0.0.1:<client-port>
leafAddr string // 127.0.0.1:<leaf-port> (for sidecar --nats-addr)
}
// Start brings up the embedded NATS server and, if a sidecar is configured,
// starts it with --nats-addr and establishes tunnels to peers.
// Returns the NATS client URL for the agent to connect to.
func (m *NATSMesh) Start() (clientURL string, err error) {
// 1. Start embedded NATS.
opts := m.buildServerOpts()
m.server, err = natsserver.NewServer(opts)
if err != nil {
return "", fmt.Errorf("nats mesh: create server: %w", err)
}
m.server.Start()
if !m.server.ReadyForConnections(5 * time.Second) {
m.server.Shutdown()
return "", fmt.Errorf("nats mesh: server not ready within 5s")
}
m.clientURL = m.server.ClientURL()
m.leafAddr = fmt.Sprintf("127.0.0.1:%d", opts.LeafNode.Port)
// 2. Start sidecar with --nats-addr (if iroh enabled).
if m.sidecar != nil && m.leafAddr != "" && m.role != NATSRoleLeaf {
// For peer/hub, sidecar needs the leaf node port for incoming tunnels.
// Sidecar startup is handled by the agent — we just record the addr.
}
// 3. Establish tunnels (if sidecar is ready and peers are known).
if m.sidecar != nil && m.endpointID != "" {
if err := m.establishTunnels(); err != nil {
// Non-fatal — tunnels can be retried.
// Log but don't fail startup.
fmt.Printf("nats mesh: tunnel establishment: %v\n", err)
}
}
return m.clientURL, nil
}
// Stop shuts down tunnels, sidecar, and embedded NATS.
func (m *NATSMesh) Stop() {
if m.server != nil {
m.server.Shutdown()
m.server.WaitForShutdown()
m.server = nil
}
}
// LeafAddr returns the leaf node listen address for the sidecar's --nats-addr.
// Empty if role is "leaf" (no leaf port).
func (m *NATSMesh) LeafAddr() string {
return m.leafAddr
}
// SetEndpointID is called after the sidecar starts and reports its EndpointId.
func (m *NATSMesh) SetEndpointID(id string) {
m.endpointID = id
}
// SetSidecar provides the sidecar reference for tunnel establishment.
func (m *NATSMesh) SetSidecar(s *sidecar) {
m.sidecar = s
}
// buildServerOpts creates nats-server options based on role.
func (m *NATSMesh) buildServerOpts() *natsserver.Options {
opts := &natsserver.Options{
Host: "127.0.0.1",
Port: -1, // random client port
NoLog: true,
NoSigs: true,
}
switch m.role {
case NATSRolePeer, NATSRoleHub:
opts.LeafNode.Port = -1 // random leaf node port
case NATSRoleLeaf:
// No leaf port — this node solicits outward only.
}
// If upstream NATS URL is configured, join as a leaf.
if m.upstream != "" {
opts.LeafNode.Remotes = []*natsserver.RemoteLeafOpts{
{URLs: []*natsserver.URL{{URL: m.upstream}}},
}
}
return opts
}
// establishTunnels tells the sidecar to open NATS tunnels to peers
// based on role and EndpointId comparison.
func (m *NATSMesh) establishTunnels() error {
if m.sidecar == nil {
return nil
}
client := &http.Client{
Transport: &http.Transport{
DialContext: sidecarDialer(m.sidecar.socketPath),
},
Timeout: 10 * time.Second,
}
for _, peerID := range m.irohPeers {
if !shouldSolicit(m.role, m.endpointID, peerID) {
continue
}
url := fmt.Sprintf("http://iroh-sidecar/nats-tunnel/%s", peerID)
resp, err := client.Post(url, "", nil)
if err != nil {
return fmt.Errorf("tunnel to %s: %w", peerID[:12], err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("tunnel to %s: HTTP %d", peerID[:12], resp.StatusCode)
}
}
return nil
}
// shouldSolicit determines whether we should initiate a NATS tunnel to a peer.
func shouldSolicit(role NATSRole, myID, peerID string) bool {
switch role {
case NATSRoleLeaf:
return true // always solicit outward
case NATSRoleHub:
return false // never solicit
case NATSRolePeer:
return myID < peerID // lower ID solicits
default:
return false
}
}
Note: sidecarDialer is a helper that returns a DialContext func for the Unix socket. Check if this already exists in sidecar.go — if so, extract and reuse. If not, define it:
func sidecarDialer(socketPath string) func(ctx context.Context, _, _ string) (net.Conn, error) {
return func(ctx context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
}
}
- [ ] Step 3: Run tests
Run: cd leaf && go test ./agent/ -run TestNATSMesh -v -count=1
Run: cd leaf && go test ./agent/ -run TestTunnelShouldSolicit -v -count=1
Expected: All PASS.
- [ ] Step 4: Commit
git add leaf/agent/nats_mesh.go leaf/agent/nats_mesh_test.go
git commit -m "feat(agent): add NATSMesh module — embedded NATS + tunnel establishment"
Task 6: Go — Wire NATSMesh into agent lifecycle
Files:
- Modify:
leaf/agent/agent.go Modify:
leaf/agent/sidecar.go[ ] Step 1: Add mesh field to Agent
In agent.go, add the mesh to the Agent struct and update Start():
The agent's Start() should:
- Create NATSMesh with config
- Call
mesh.Start()to get clientURL - Connect NATS client to clientURL (instead of
cfg.NATSUpstream) - If iroh enabled: start sidecar with
--nats-addr mesh.LeafAddr() - After sidecar ready:
mesh.SetEndpointID(status.EndpointID)+mesh.SetSidecar(sidecar)+mesh.establishTunnels() Continue with ServeNATS, HTTP, poll loop as before
[ ] Step 2: Update sidecar.go to accept natsAddr
Add natsAddr field to the sidecar struct:
type sidecar struct {
binPath string
socketPath string
keyPath string
callbackURL string
alpn string
natsAddr string // NEW: optional, for --nats-addr
cmd *exec.Cmd
exited chan struct{}
exitErr error
}
In spawn(), conditionally add the flag:
args := []string{
"--socket", s.socketPath,
"--key-path", s.keyPath,
"--callback", s.callbackURL,
"--alpn", s.alpn,
}
if s.natsAddr != "" {
args = append(args, "--nats-addr", s.natsAddr)
}
s.cmd = exec.Command(s.binPath, args...)
- [ ] Step 3: Update syncTargets in agent.go
When NATSMesh is active, the agent should use a single NATSTransport connected to the mesh's clientURL instead of separate NATS + iroh targets. The iroh peers are now reached via NATS leaf node routing through the tunnel.
Keep IrohTransport targets as a fallback for backwards compat with peers that don't have embedded NATS.
- [ ] Step 4: Run agent tests
Run: cd leaf && go test ./agent/ -v -count=1 -timeout=60s
Expected: All existing tests PASS. New mesh tests PASS.
- [ ] Step 5: Commit
git add leaf/agent/
git commit -m "feat(agent): wire NATSMesh into agent lifecycle"
Task 7: Integration test — P2P sync over NATS-over-iroh
Files:
Create:
sim/nats_iroh_test.go[ ] Step 1: Write the integration test
This test requires two iroh sidecars. It follows the pattern of sim/iroh_test.go:
func TestNATSOverIrohSync(t *testing.T) {
if testing.Short() {
t.Skip("skipping NATS-over-iroh integration test in short mode")
}
findIrohSidecar(t) // skip if binary not found
dir := t.TempDir()
projCode := "abcdef0123456789abcdef0123456789abcdef01"
srvCode := "fedcba9876543210fedcba9876543210fedcba98"
// 1. Create two repos.
// 2. Seed blobs into repo 0.
// 3. Start two agents with NATSRole=peer, IrohEnabled=true, no NATSUpstream.
// Each agent uses NATSMesh (embedded NATS + sidecar).
// 4. Exchange EndpointIds, configure peers, establish tunnels.
// 5. Trigger sync on both agents.
// 6. Verify convergence: repo 1 has all blobs from repo 0.
}
The full test will closely mirror TestIrohConvergence but with NATS-over-iroh tunnels instead of direct IrohTransport.
- [ ] Step 2: Run the test
Run: cd sim && go test -run TestNATSOverIrohSync -v -count=1 -timeout=120s
Expected: PASS — blobs sync from agent 0 to agent 1 via embedded NATS over iroh tunnels.
- [ ] Step 3: Commit
git add sim/nats_iroh_test.go
git commit -m "test: integration test for P2P sync over NATS-over-iroh (EDG-59)"
Task 8: Full test suite verification
Files: None (verification only)
- [ ] Step 1: Build sidecar
Run: cd iroh-sidecar && cargo build
Expected: Successful.
- [ ] Step 2: Run leaf agent tests
Run: cd leaf && go test ./... -v -count=1 -timeout=60s
Expected: All PASS.
- [ ] Step 3: Run sim tests (non-short)
Run: go test ./sim/ -v -count=1 -timeout=120s
Expected: All PASS (iroh tests skip if sidecar binary not present).
- [ ] Step 4: Run EdgeSync full suite
Run: make test
Expected: PASS.