node-app-build 6.12.1

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`.
///
/// Returns one `(instance, rendered error)` pair per instance that failed to
/// onboard. The dev TUI ignores the return value — the agent step stays
/// advisory there, as documented above. `harness up` does NOT: onboarding is a
/// hard precondition for every probe, so it aborts on a non-empty result rather
/// than continuing and failing later on the missing session file, which reads
/// as "did onboarding run?" and hides the real error.
#[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}': 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.
    //
    // `/api/node/info` answers as soon as HTTP is up, but reports an EMPTY
    // node_id until LDK has finished starting. Persisting that empty string is
    // silent and permanent: nothing re-reads it, so peer cross-seeding below
    // skips the instance and every later `channel-open`/`pay` against it fails
    // on `invalid node_id: malformed public key` from a peer string that is
    // just "@127.0.0.1:9736". Wait for a real value when the caller needs one.
    match resolve_node_id(&client, &session.token, wait_for_node_id, &instance, log_tx) {
        Ok(node_id) => session.node_id = node_id,
        // Advisory for `node-app dev` (LDK may simply be off), fatal for the
        // harness: persisting an empty node_id there only defers the failure to
        // `connect_peer`, which reports it as "malformed public key" — a error
        // that says nothing about LDK having been slow to start.
        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)
}

/// How long to keep asking a daemon for its LDK node id before giving up.
const NODE_ID_WAIT: std::time::Duration = std::time::Duration::from_secs(60);

/// Fetch the daemon's LDK node id, optionally waiting for LDK to publish one.
///
/// `wait` is false for `node-app dev`, where the daemon may legitimately run
/// with LDK off and an empty node id is the steady state — polling there would
/// add [`NODE_ID_WAIT`] of dead time to every dev boot. It is true for the
/// harness, which always builds `node-server --features agentic_payments` and
/// cannot drive a single Lightning probe without real node ids.
fn resolve_node_id(
    client: &AgentHttpClient,
    token: &str,
    wait: bool,
    instance: &str,
    log_tx: Option<&LogTx>,
) -> Result<String> {
    // Two distinct "LDK isn't ready yet" shapes, both transient and both fatal
    // to Lightning probes if accepted as final: an empty node_id, and a plain
    // HTTP 500 from `/api/node/info`. Retry on either.
    let first = client.get_node_id(token);
    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(token) {
            Ok(id) if !id.is_empty() => return Ok(id),
            Ok(_) => last_error = None,
            Err(e) => last_error = Some(e),
        }
    }
}

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(())
}

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])
    }
}

/// 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))
}