node-app-build 6.5.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `node-app dev --agent` — AI-agent-friendly auth bootstrap.
//!
//! This module bolts an opt-in auth/identity layer onto the existing dev
//! loop. After the dev orchestrator has brought every instance up and
//! sideloaded the app for the first time, [`run_agent_setup`] does, per
//! `DaemonHandle`:
//!
//!   1. Loads or generates a BIP39 mnemonic (persisted at
//!      `<dev_dir>/<instance>-agent-session.json`).
//!   2. Either onboards (first run on a fresh node) or logs in (reusing
//!      the saved seed when the node already has a primary owner) — both
//!      paths land at a fresh JWT + refresh token.
//!   3. Fetches the daemon's `node_id` via `GET /api/node/info`.
//!   4. Rewrites the session file with the rotated JWT + freshly observed
//!      `node_id`.
//!
//! When two instances are up, it then cross-seeds each instance's IP pool
//! with the other's `127.0.0.1:<port>` endpoint so `L402HttpClient` can
//! resolve peer URLs without extra setup.
//!
//! Failure to onboard ONE instance does not abort the rest — each handle
//! gets its own error path, and the entire agent step is best-effort
//! advisory: it must never crash the dev loop. The dev TUI keeps running
//! even if a node refuses auth.
//!
//! Reference for byte-level behavior:
//!   - `tests/e2e/src/harness/auth.ts`           (onboard + login flow)
//!   - `tests/e2e/src/harness/test-harness.ts`   (cross-seed peer step)

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};

/// Entry point invoked from `commands::dev::run` after the first
/// successful sideload, gated on `--agent`.
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()
            );
        }
    };

    // Refresh node_id every run — cheap, and the alice/bob node_id changes
    // whenever the LDK signer seed is regenerated.
    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])
    }
}

/// Strip the scheme from `http://127.0.0.1:3001` and split host/port.
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))
}