node-app-build 7.2.3

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
//! `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(crate) mod peer_plane;
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);

/// How long [`await_peer_links`] gives iroh's mesh reconcile to dial the
/// freshly ingested peers. Measured on this Mac: the link was up ~20 s after
/// the cards were ingested; the reconcile ticks about once a minute.
const LINK_WAIT: std::time::Duration = std::time::Duration::from_secs(150);
const LINK_POLL: std::time::Duration = std::time::Duration::from_secs(5);

/// One instance's card, ready to hand to the others.
struct SharedCard {
    name: String,
    node_id: String,
    card: Value,
    /// The iroh endpoint the card names, when the instance runs node-app-iroh.
    iroh_endpoint: Option<String>,
}

/// Introduce the instances to each other by exchanging gossip cards, then wait
/// for them to connect.
///
/// 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, and — when the instance runs node-app-iroh — only after
/// the iroh endpoint is on it ([`peer_plane`]'s module doc says why).
/// 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)
    };

    let mut cards: Vec<SharedCard> = Vec::with_capacity(sessions.len());
    for (name, session) in sessions {
        let Some(client) = client_for(name) else { continue };
        if let Some(card) = read_shareable_card(&client, name, &session.token, log_tx) {
            cards.push(card);
        }
    }

    for (i_name, i_session) in sessions {
        let Some(own) = client_for(i_name) else { continue };
        for card in cards.iter().filter(|card| &card.name != i_name) {
            match own.invoke_capability(
                &i_session.token,
                "core.gossip.process_node_metadata",
                json!({ "node_id": card.node_id, "metadata": card.card }),
            ) {
                Ok(_) => tui::sys_log(
                    log_tx,
                    format!(
                        "✓ {i_name}: ingested {}'s gossip card ({}){}",
                        card.name,
                        short(&card.node_id, 12),
                        if card.iroh_endpoint.is_some() { ", iroh endpoint included" } else { "" }
                    ),
                ),
                Err(e) => tui::sys_log(
                    log_tx,
                    format!("⚠ {i_name}: ingesting {}'s gossip card failed: {e:#}", card.name),
                ),
            }
        }
    }

    await_peer_links(&cards, sessions, &client_for, log_tx);
    Ok(())
}

/// Publish one instance's card and read it back, with its iroh endpoint on it
/// when the instance runs node-app-iroh.
fn read_shareable_card(
    client: &AgentHttpClient,
    name: &str,
    token: &str,
    log_tx: Option<&LogTx>,
) -> Option<SharedCard> {
    // No iroh app ⇒ "No provider registered" ⇒ None: share the card as it is.
    let iroh_endpoint = client
        .invoke_capability(token, "iroh.publish_endpoint", json!({}))
        .ok()
        .as_ref()
        .and_then(peer_plane::iroh_endpoint_id);
    // 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) = client.invoke_capability(token, "core.gossip.publish_self_card", json!({})) {
        tui::sys_log(log_tx, format!("⚠ {name}: publishing its gossip card failed: {e:#}"));
    }
    let mut card = None;
    for attempt in 0..CARD_WAIT_ATTEMPTS {
        match client.invoke_capability(token, "core.gossip.relay.self", json!({})) {
            Ok(answer) => card = capability_result(&answer).get("card").cloned().filter(Value::is_object),
            Err(e) => {
                tui::sys_log(log_tx, format!("⚠ {name}: no gossip card to share: {e:#}"));
                return None;
            }
        }
        let complete = match (&card, &iroh_endpoint) {
            (Some(card), Some(endpoint)) => peer_plane::card_names_iroh_endpoint(card, endpoint),
            (Some(_), None) => true,
            (None, _) => false,
        };
        if complete {
            break;
        }
        if attempt + 1 < CARD_WAIT_ATTEMPTS {
            std::thread::sleep(CARD_WAIT_INTERVAL);
            // A concurrent writer may have dropped the iroh keys again.
            let _ = client.invoke_capability(token, "iroh.publish_endpoint", json!({}));
            let _ = client.invoke_capability(token, "core.gossip.publish_self_card", json!({}));
        }
    }
    let Some(card) = card else {
        tui::sys_log(log_tx, format!("⚠ {name}: its gossip card did not appear after publishing it"));
        return None;
    };
    if let Some(endpoint) = &iroh_endpoint {
        if !peer_plane::card_names_iroh_endpoint(&card, endpoint) {
            tui::sys_log(
                log_tx,
                format!(
                    "⚠ {name}: its card still lacks iroh endpoint {} — sharing it anyway; \
                     peers will not dial it over iroh until gossip carries a newer card",
                    short(endpoint, 12)
                ),
            );
        }
    }
    let node_id = card.get("node_id").and_then(Value::as_str).unwrap_or_default().to_string();
    Some(SharedCard { name: name.to_string(), node_id, card, iroh_endpoint })
}

/// Wait until every instance that runs iroh lists a live link to every other
/// one (`core.network.list_connections`), and say which path each took.
/// Advisory, bounded by [`LINK_WAIT`]; skipped when fewer than two instances
/// run iroh.
fn await_peer_links(
    cards: &[SharedCard],
    sessions: &[(String, AgentSession)],
    client_for: &dyn Fn(&str) -> Option<AgentHttpClient>,
    log_tx: Option<&LogTx>,
) {
    let meshed: Vec<&SharedCard> = cards.iter().filter(|card| card.iroh_endpoint.is_some()).collect();
    if meshed.len() < 2 {
        if !cards.is_empty() {
            tui::sys_log(
                log_tx,
                "→ skipping the peer-link wait: fewer than two instances run node-app-iroh \
                 (core.network.list_connections lists only iroh links)",
            );
        }
        return;
    }
    tui::sys_log(log_tx, "→ waiting for the instances to connect over iroh…");
    let deadline = std::time::Instant::now() + LINK_WAIT;
    let mut reported: std::collections::HashSet<(String, String)> = Default::default();
    loop {
        let mut pending = 0usize;
        for own in &meshed {
            let Some(token) = sessions.iter().find(|(n, _)| n == &own.name).map(|(_, s)| s.token.as_str()) else {
                continue;
            };
            let links = client_for(&own.name)
                .and_then(|client| client.invoke_capability(token, "core.network.list_connections", json!({})).ok())
                .map(|answer| peer_plane::linked_peers(&answer))
                .unwrap_or_default();
            for peer in meshed.iter().filter(|peer| peer.name != own.name) {
                match links.iter().find(|link| link.node_id == peer.node_id) {
                    Some(link) => {
                        if reported.insert((own.name.clone(), peer.name.clone())) {
                            tui::sys_log(
                                log_tx,
                                format!(
                                    "✓ {} → {}: {} link up (path: {})",
                                    own.name,
                                    peer.name,
                                    if link.transport.is_empty() { "iroh" } else { &link.transport },
                                    if link.kind.is_empty() { "unknown" } else { &link.kind },
                                ),
                            );
                        }
                    }
                    None => pending += 1,
                }
            }
        }
        if pending == 0 {
            return;
        }
        if std::time::Instant::now() >= deadline {
            tui::sys_log(
                log_tx,
                format!(
                    "⚠ {pending} peer link(s) still down after {}s — check `harness invoke <node> \
                     core.network.list_connection_states` (its events name the failing dial)",
                    LINK_WAIT.as_secs()
                ),
            );
            return;
        }
        std::thread::sleep(LINK_POLL);
    }
}

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