node-app-build 7.1.10

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 reuses the saved
//!      session when the node already has a primary owner (the minimal core
//!      has no login route; an expired owner token is re-issued from the
//!      instance's own JWT_SECRET).
//!   3. Fetches the daemon's `node_id` via `GET /.well-known/client-node-origin`.
//!   4. Rewrites the session file with the current JWT + freshly observed
//!      `node_id`.
//!
//! When two instances are up, it then has each ingest the other's gossip
//! card, which is where the minimal core learns a peer's endpoints.
//!
//! 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`           (onboarding flow)

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

/// 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), _) => {
            // The minimal core (#2939) serves no challenge/verify login and no
            // refresh route, so a saved session is reused as-is. Proving it
            // through a session-bound client re-issues the owner token from
            // the instance's own JWT_SECRET when it has expired (see
            // `client::reissue_from_instance_secret`) and persists that.
            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()
            );
        }
    };

    // Refresh node_id every run — cheap, and the alice/bob node_id changes
    // whenever the LDK signer seed is regenerated.
    //
    // The node id is ldk-node's, and is absent until that app 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, 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,
    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
    // error from `/.well-known/client-node-origin` while ldk-node is still
    // starting. Retry on either.
    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),
        }
    }
}

/// How long [`cross_seed_peers`] waits for a just-published card to read back.
const CARD_WAIT_ATTEMPTS: u32 = 10;
const CARD_WAIT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);

/// Introduce the instances to each other by exchanging gossip cards.
///
/// The pre-cut host had a loopback-only `POST /api/v2/internal/test/seed-peer`
/// that wrote an IP-pool row; the minimal core has neither the route nor the
/// table — a peer's endpoints come only from its gossip card (#3090). So each
/// instance's own card (`core.gossip.relay.self`, served by node-app-gossip)
/// is ingested by every other one (`core.gossip.process_node_metadata`), the
/// same card a seed relay would have carried. Each card is published first
/// (`core.gossip.publish_self_card`) rather than waiting for the gossip app's
/// own cron republish. Advisory: a node without the
/// gossip app simply stays undiscovered, and the warning says why.
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;
            };
            // The gossip app publishes its card on a cron tick about a minute
            // after boot; publish it now instead, then read it back.
            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(())
}

/// The capability's own answer out of a `POST /api/v2/system/capabilities/invoke`
/// body, which wraps it as `{capability, provider, result}`.
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::*;

    // WHY (2026-09-22): the card exchange read `card` off the invoke envelope
    // itself, found none, and reported every card as unpublished.
    #[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);
    }
}