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 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);
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)
};
for (i, (i_name, i_session)) in sessions.iter().enumerate() {
for (j, (j_name, j_session)) in sessions.iter().enumerate() {
if i == j {
continue;
}
let (Some(own), Some(peer)) = (client_for(i_name), client_for(j_name)) else {
continue;
};
if let Err(e) =
peer.invoke_capability(&j_session.token, "core.gossip.publish_self_card", json!({}))
{
tui::sys_log(log_tx, format!("⚠ {j_name}: publishing its gossip card failed: {e:#}"));
}
let mut card = None;
for _ in 0..CARD_WAIT_ATTEMPTS {
match peer.invoke_capability(&j_session.token, "core.gossip.relay.self", json!({})) {
Ok(answer) => card = capability_result(&answer)
.get("card")
.cloned()
.filter(|card| card.is_object()),
Err(e) => {
tui::sys_log(log_tx, format!("⚠ {j_name}: no gossip card to share: {e:#}"));
break;
}
}
if card.is_some() {
break;
}
std::thread::sleep(CARD_WAIT_INTERVAL);
}
let Some(card) = card else {
tui::sys_log(log_tx, format!("⚠ {j_name}: its gossip card did not appear after publishing it"));
continue;
};
let node_id = card.get("node_id").and_then(Value::as_str).unwrap_or_default().to_string();
match own.invoke_capability(
&i_session.token,
"core.gossip.process_node_metadata",
json!({ "node_id": node_id, "metadata": card }),
) {
Ok(_) => tui::sys_log(
log_tx,
format!("✓ {i_name}: ingested {j_name}'s gossip card ({})", short(&node_id, 12)),
),
Err(e) => tui::sys_log(
log_tx,
format!("⚠ {i_name}: ingesting {j_name}'s gossip card failed: {e:#}"),
),
}
}
}
Ok(())
}
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);
}
}