use anyhow::{Context, Result};
use serde_json::{json, Value};
use chrono::Utc;
use crate::tui::{self, LogTx};
use super::host::DaemonHandle;
pub mod client;
mod keys;
pub(crate) mod peer_plane;
pub mod session;
use client::AgentHttpClient;
use keys::AgentIdentity;
use session::{redact_token, AgentSession};
#[must_use]
pub fn run_agent_setup(
handles: &[DaemonHandle],
log_tx: Option<&LogTx>,
wait_for_node_id: bool,
) -> Vec<(String, String)> {
tui::sys_log(log_tx, "→ agent mode: onboarding instances…");
let mut sessions: Vec<(String, AgentSession)> = Vec::with_capacity(handles.len());
let mut failures: Vec<(String, String)> = Vec::new();
for handle in handles {
match bootstrap_handle(handle, log_tx, wait_for_node_id) {
Ok(session) => sessions.push((handle.name.clone(), session)),
Err(e) => {
let instance = instance_label(handle).to_string();
let rendered = format!("{e:#}");
tui::sys_log(
log_tx,
format!("✗ agent setup for '{instance}' failed: {rendered}"),
);
failures.push((instance, rendered));
}
}
}
if sessions.len() >= 2 {
if let Err(e) = cross_seed_peers(handles, &sessions, log_tx) {
tui::sys_log(log_tx, format!("⚠ peer cross-seed skipped: {:#}", e));
}
}
failures
}
fn bootstrap_handle(
handle: &DaemonHandle,
log_tx: Option<&LogTx>,
wait_for_node_id: bool,
) -> Result<AgentSession> {
let base_url = handle
.api_base_url
.as_deref()
.ok_or_else(|| anyhow::anyhow!(
"agent mode requires a daemon with a known HTTP API endpoint. \
Only `--daemon monorepo` exposes this today; rerun without \
`--agent` or switch daemon host."
))?;
let instance = instance_label(handle).to_string();
let client = AgentHttpClient::new(base_url.to_string());
let existing = AgentSession::load(&handle.dev_dir, &instance)
.with_context(|| format!("load existing session for '{instance}'"))?;
let is_unowned = client.is_unowned().unwrap_or(false);
let now = Utc::now();
let mut session = match (existing, is_unowned) {
(None, true) => {
tui::sys_log(log_tx, format!("→ '{instance}': onboarding fresh node…"));
let identity = AgentIdentity::generate()?;
let challenge = client.create_onboarding_challenge()?;
let signature = identity.sign_challenge(&challenge.challenge)?;
let username = format!("agent-{instance}");
let auth = client.complete_onboarding(
&identity.public_key_hex,
&challenge.challenge_id,
&signature,
&username,
)?;
AgentSession {
instance: instance.clone(),
base_url: base_url.to_string(),
node_id: String::new(),
public_key: identity.public_key_hex,
secret_key_hex: identity.secret_key_hex,
mnemonic: identity.mnemonic,
token: auth.token,
refresh_token: auth.refresh_token,
onboarded_at: now,
last_login_at: now,
}
}
(Some(existing), _) => {
tui::sys_log(
log_tx,
format!("→ '{instance}': reusing the saved owner session…"),
);
let session_path = AgentSession::file_path(&handle.dev_dir, &instance);
AgentHttpClient::with_session(base_url.to_string(), session_path.clone())
.probe_token(&existing.token)
.with_context(|| {
format!(
"the saved owner session for '{instance}' at {} is not accepted by the \
node — rerun with `--clean` to onboard it fresh",
session_path.display()
)
})?;
let token = AgentSession::read_token(&session_path)
.with_context(|| format!("re-read the owner token from {}", session_path.display()))?;
AgentSession {
token,
last_login_at: now,
..existing
}
}
(None, false) => {
anyhow::bail!(
"instance '{instance}' already has a primary owner but no \
saved session file at {}. Reset the instance data (delete \
the lightning.db) or restore the previous \
{instance}-agent-session.json before re-running with --agent.",
AgentSession::file_path(&handle.dev_dir, &instance).display()
);
}
};
match resolve_node_id(&client, wait_for_node_id, &instance, log_tx) {
Ok(node_id) => session.node_id = node_id,
Err(e) if wait_for_node_id => return Err(e),
Err(e) => tui::sys_log(
log_tx,
format!("⚠ '{instance}': could not refresh node_id: {:#}", e),
),
}
let written = session.save(&handle.dev_dir)?;
tui::sys_log(
log_tx,
format!(
"✓ '{instance}': session at {} jwt={} node_id={}",
written.display(),
redact_token(&session.token),
short(&session.node_id, 12)
),
);
Ok(session)
}
const NODE_ID_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
fn resolve_node_id(
client: &AgentHttpClient,
wait: bool,
instance: &str,
log_tx: Option<&LogTx>,
) -> Result<String> {
let first = client.get_node_id();
match first {
Ok(ref id) if !id.is_empty() => return Ok(id.clone()),
_ if !wait => return first,
_ => {}
}
tui::sys_log(
log_tx,
format!("→ '{instance}': waiting for LDK to publish a node id…"),
);
let deadline = std::time::Instant::now() + NODE_ID_WAIT;
let mut last_error = first.err();
loop {
if std::time::Instant::now() >= deadline {
let cause = last_error
.map(|e| format!("{e:#}"))
.unwrap_or_else(|| "node_id stayed empty".into());
anyhow::bail!(
"LDK reported no node id within {}s ({cause}) — Lightning probes \
(channel-open, pay) cannot address this instance",
NODE_ID_WAIT.as_secs()
);
}
std::thread::sleep(std::time::Duration::from_millis(500));
match client.get_node_id() {
Ok(id) if !id.is_empty() => return Ok(id),
Ok(_) => last_error = None,
Err(e) => last_error = Some(e),
}
}
}
const CARD_WAIT_ATTEMPTS: u32 = 10;
const CARD_WAIT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
const LINK_WAIT: std::time::Duration = std::time::Duration::from_secs(150);
const LINK_POLL: std::time::Duration = std::time::Duration::from_secs(5);
struct SharedCard {
name: String,
node_id: String,
card: Value,
iroh_endpoint: Option<String>,
}
fn cross_seed_peers(
handles: &[DaemonHandle],
sessions: &[(String, AgentSession)],
log_tx: Option<&LogTx>,
) -> Result<()> {
tui::sys_log(log_tx, "→ exchanging gossip cards between instances…");
let client_for = |name: &str| {
handles
.iter()
.find(|h| instance_label(h) == name)
.and_then(|h| h.api_base_url.clone())
.map(AgentHttpClient::new)
};
let mut cards: Vec<SharedCard> = Vec::with_capacity(sessions.len());
for (name, session) in sessions {
let Some(client) = client_for(name) else { continue };
if let Some(card) = read_shareable_card(&client, name, &session.token, log_tx) {
cards.push(card);
}
}
for (i_name, i_session) in sessions {
let Some(own) = client_for(i_name) else { continue };
for card in cards.iter().filter(|card| &card.name != i_name) {
match own.invoke_capability(
&i_session.token,
"core.gossip.process_node_metadata",
json!({ "node_id": card.node_id, "metadata": card.card }),
) {
Ok(_) => tui::sys_log(
log_tx,
format!(
"✓ {i_name}: ingested {}'s gossip card ({}){}",
card.name,
short(&card.node_id, 12),
if card.iroh_endpoint.is_some() { ", iroh endpoint included" } else { "" }
),
),
Err(e) => tui::sys_log(
log_tx,
format!("⚠ {i_name}: ingesting {}'s gossip card failed: {e:#}", card.name),
),
}
}
}
await_peer_links(&cards, sessions, &client_for, log_tx);
Ok(())
}
fn read_shareable_card(
client: &AgentHttpClient,
name: &str,
token: &str,
log_tx: Option<&LogTx>,
) -> Option<SharedCard> {
let iroh_endpoint = client
.invoke_capability(token, "iroh.publish_endpoint", json!({}))
.ok()
.as_ref()
.and_then(peer_plane::iroh_endpoint_id);
if let Err(e) = client.invoke_capability(token, "core.gossip.publish_self_card", json!({})) {
tui::sys_log(log_tx, format!("⚠ {name}: publishing its gossip card failed: {e:#}"));
}
let mut card = None;
for attempt in 0..CARD_WAIT_ATTEMPTS {
match client.invoke_capability(token, "core.gossip.relay.self", json!({})) {
Ok(answer) => card = capability_result(&answer).get("card").cloned().filter(Value::is_object),
Err(e) => {
tui::sys_log(log_tx, format!("⚠ {name}: no gossip card to share: {e:#}"));
return None;
}
}
let complete = match (&card, &iroh_endpoint) {
(Some(card), Some(endpoint)) => peer_plane::card_names_iroh_endpoint(card, endpoint),
(Some(_), None) => true,
(None, _) => false,
};
if complete {
break;
}
if attempt + 1 < CARD_WAIT_ATTEMPTS {
std::thread::sleep(CARD_WAIT_INTERVAL);
let _ = client.invoke_capability(token, "iroh.publish_endpoint", json!({}));
let _ = client.invoke_capability(token, "core.gossip.publish_self_card", json!({}));
}
}
let Some(card) = card else {
tui::sys_log(log_tx, format!("⚠ {name}: its gossip card did not appear after publishing it"));
return None;
};
if let Some(endpoint) = &iroh_endpoint {
if !peer_plane::card_names_iroh_endpoint(&card, endpoint) {
tui::sys_log(
log_tx,
format!(
"⚠ {name}: its card still lacks iroh endpoint {} — sharing it anyway; \
peers will not dial it over iroh until gossip carries a newer card",
short(endpoint, 12)
),
);
}
}
let node_id = card.get("node_id").and_then(Value::as_str).unwrap_or_default().to_string();
Some(SharedCard { name: name.to_string(), node_id, card, iroh_endpoint })
}
fn await_peer_links(
cards: &[SharedCard],
sessions: &[(String, AgentSession)],
client_for: &dyn Fn(&str) -> Option<AgentHttpClient>,
log_tx: Option<&LogTx>,
) {
let meshed: Vec<&SharedCard> = cards.iter().filter(|card| card.iroh_endpoint.is_some()).collect();
if meshed.len() < 2 {
if !cards.is_empty() {
tui::sys_log(
log_tx,
"→ skipping the peer-link wait: fewer than two instances run node-app-iroh \
(core.network.list_connections lists only iroh links)",
);
}
return;
}
tui::sys_log(log_tx, "→ waiting for the instances to connect over iroh…");
let deadline = std::time::Instant::now() + LINK_WAIT;
let mut reported: std::collections::HashSet<(String, String)> = Default::default();
loop {
let mut pending = 0usize;
for own in &meshed {
let Some(token) = sessions.iter().find(|(n, _)| n == &own.name).map(|(_, s)| s.token.as_str()) else {
continue;
};
let links = client_for(&own.name)
.and_then(|client| client.invoke_capability(token, "core.network.list_connections", json!({})).ok())
.map(|answer| peer_plane::linked_peers(&answer))
.unwrap_or_default();
for peer in meshed.iter().filter(|peer| peer.name != own.name) {
match links.iter().find(|link| link.node_id == peer.node_id) {
Some(link) => {
if reported.insert((own.name.clone(), peer.name.clone())) {
tui::sys_log(
log_tx,
format!(
"✓ {} → {}: {} link up (path: {})",
own.name,
peer.name,
if link.transport.is_empty() { "iroh" } else { &link.transport },
if link.kind.is_empty() { "unknown" } else { &link.kind },
),
);
}
}
None => pending += 1,
}
}
}
if pending == 0 {
return;
}
if std::time::Instant::now() >= deadline {
tui::sys_log(
log_tx,
format!(
"⚠ {pending} peer link(s) still down after {}s — check `harness invoke <node> \
core.network.list_connection_states` (its events name the failing dial)",
LINK_WAIT.as_secs()
),
);
return;
}
std::thread::sleep(LINK_POLL);
}
}
fn capability_result(answer: &Value) -> &Value {
answer.get("result").unwrap_or(answer)
}
pub(crate) fn instance_label(handle: &DaemonHandle) -> &str {
if handle.name.is_empty() {
"default"
} else {
&handle.name
}
}
fn short(value: &str, n: usize) -> String {
if value.len() <= n {
value.to_string()
} else {
format!("{}…", &value[..n])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_capability_answer_is_read_from_inside_the_invoke_envelope() {
let enveloped = json!({
"capability": "core.gossip.relay.self",
"provider": "gossip",
"result": { "card": { "node_id": "02ab" } },
});
assert_eq!(capability_result(&enveloped)["card"]["node_id"], "02ab");
let bare = json!({ "card": null });
assert_eq!(capability_result(&bare), &bare);
}
}