use anyhow::{Context, Result};
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};
pub fn run_agent_setup(handles: &[DaemonHandle], log_tx: Option<&LogTx>) {
tui::sys_log(log_tx, "→ agent mode: onboarding instances…");
let mut sessions: Vec<(String, AgentSession)> = Vec::with_capacity(handles.len());
for handle in handles {
match bootstrap_handle(handle, log_tx) {
Ok(session) => sessions.push((handle.name.clone(), session)),
Err(e) => tui::sys_log(
log_tx,
format!(
"✗ agent setup for '{}' failed: {:#}",
instance_label(handle),
e
),
),
}
}
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));
}
}
}
fn bootstrap_handle(handle: &DaemonHandle, log_tx: Option<&LogTx>) -> 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}': logging in with saved seed…"),
);
let identity = AgentIdentity::from_phrase(&existing.mnemonic)?;
let challenge = client.create_login_challenge(&identity.public_key_hex)?;
let signature = identity.sign_challenge(&challenge.challenge)?;
let auth = client.verify_login(
&identity.public_key_hex,
&challenge.challenge_id,
&signature,
)?;
AgentSession {
instance: instance.clone(),
base_url: base_url.to_string(),
node_id: existing.node_id.clone(),
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: existing.onboarded_at,
last_login_at: now,
}
}
(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 client.get_node_id(&session.token) {
Ok(node_id) => session.node_id = node_id,
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)
}
fn cross_seed_peers(
handles: &[DaemonHandle],
sessions: &[(String, AgentSession)],
log_tx: Option<&LogTx>,
) -> Result<()> {
tui::sys_log(log_tx, "→ cross-seeding peer IP-pool entries…");
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(self_handle) = handles.iter().find(|h| instance_label(h) == *i_name) else {
continue;
};
let Some(self_base) = self_handle.api_base_url.as_deref() else {
continue;
};
let (peer_ip, peer_port) = parse_host_port(&j_session.base_url)?;
if j_session.node_id.is_empty() {
tui::sys_log(
log_tx,
format!(
"⚠ skip seed {i_name} → {j_name}: peer node_id unknown"
),
);
continue;
}
let client = AgentHttpClient::new(self_base.to_string());
match client.seed_peer_endpoint(
&i_session.token,
&j_session.node_id,
&peer_ip,
peer_port,
) {
Ok(()) => tui::sys_log(
log_tx,
format!("✓ {i_name}: seeded peer {j_name} @ {peer_ip}:{peer_port}"),
),
Err(e) => tui::sys_log(
log_tx,
format!("⚠ {i_name}: seed peer {j_name} failed: {:#}", e),
),
}
}
}
Ok(())
}
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])
}
}
fn parse_host_port(base_url: &str) -> Result<(String, u16)> {
let without_scheme = base_url
.strip_prefix("http://")
.or_else(|| base_url.strip_prefix("https://"))
.unwrap_or(base_url);
let trimmed = without_scheme.trim_end_matches('/');
let (host, port) = trimmed
.rsplit_once(':')
.with_context(|| format!("base_url '{base_url}' has no :PORT"))?;
let port: u16 = port
.parse()
.with_context(|| format!("parse port from '{base_url}'"))?;
Ok((host.to_string(), port))
}