use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
#[cfg(unix)]
use libc;
use serde::Serialize;
use serde_json::json;
use crate::commands::dev;
use crate::commands::dev::agent::client::{AgentHttpClient, ProbeError};
use crate::commands::dev::agent::session::AgentSession;
use crate::commands::dev::host::{self, DaemonHost, InstanceProfile, Mode};
use crate::commands::harness::bitcoind::Bitcoind;
use crate::commands::harness::ports;
use crate::commands::harness::state::{
resolve_state_path, state_path, BitcoindState, ChannelState, HarnessState, InstanceState,
};
use crate::commands::harness::BitcoindMode;
pub fn up(
monorepo_path: std::path::PathBuf,
bitcoind_mode: BitcoindMode,
with_channel: bool,
clean: bool,
client_node: Option<String>,
operation_mode: bool,
) -> Result<()> {
let serve_client_node = client_node.is_some();
let lane_name = |base: &str| {
crate::commands::dev::host::monorepo::lane_instance_name(base, serve_client_node)
};
let _lock = crate::commands::harness::state::HarnessLock::acquire(&monorepo_path)?;
if clean {
if let Ok(path) = state_path(&monorepo_path) {
if path.exists() {
let _ = std::fs::remove_file(&path);
}
}
for base in ["alice", "bob"] {
let instance = lane_name(base);
let instance = instance.as_str();
if let Ok(env_dir) = crate::commands::dev::host::monorepo::monorepo_dev_dir(
&monorepo_path,
instance,
None,
).map(|dev_dir| dev_dir.parent().unwrap_or(&dev_dir).to_path_buf()) {
for name in ["dev.db", "dev.db-shm", "dev.db-wal", "lightning.db"] {
let _ = std::fs::remove_file(env_dir.join(name));
}
for name in ["ldk_data", "ldk_node_data", "ldk_node_data_backup"] {
let _ = std::fs::remove_dir_all(env_dir.join(name));
}
let dev_dir = env_dir.join("dev-apps");
let _ = std::fs::remove_file(
crate::commands::dev::agent::session::AgentSession::file_path(&dev_dir, instance)
);
}
}
}
for base in ["alice", "bob"] {
let instance = lane_name(base);
let instance = instance.as_str();
if let Ok(dev_dir) = crate::commands::dev::host::monorepo::monorepo_dev_dir(
&monorepo_path,
instance,
None,
) {
let session_exists =
crate::commands::dev::agent::session::AgentSession::file_path(&dev_dir, instance).exists();
let env_dir = dev_dir.parent().unwrap_or(&dev_dir).to_path_buf();
let db_exists = env_dir.join("dev.db").exists();
if db_exists && !session_exists {
eprintln!(
"harness: {instance} DB exists but session missing — \
wiping DB for clean re-onboard"
);
for name in ["dev.db", "dev.db-shm", "dev.db-wal", "lightning.db"] {
let _ = std::fs::remove_file(env_dir.join(name));
}
for name in ["ldk_data", "ldk_node_data", "ldk_node_data_backup"] {
let _ = std::fs::remove_dir_all(env_dir.join(name));
}
}
}
}
preflight_apps(&monorepo_path)?;
dev::install_shutdown_handler();
let btc = Bitcoind::ensure(bitcoind_mode).context("bring up regtest bitcoind")?;
let mode = Mode::Monorepo { path: monorepo_path.clone() };
let profiles = [InstanceProfile::alice(), InstanceProfile::bob()];
let state_for_allocation = HarnessState::load_for(&monorepo_path).ok();
let port_overrides: Vec<Option<ports::PortSet>> = profiles
.iter()
.map(|p| -> Result<Option<ports::PortSet>> {
if !serve_client_node {
return Ok(None);
}
let lane = lane_name(&p.name);
let expected_env_path = crate::commands::dev::host::monorepo::monorepo_dev_dir(
&monorepo_path,
&lane,
None,
)?
.parent()
.map(|env_dir| env_dir.join("daemon.env"));
let base =
ports::PortSet::base_for(&p.name, p.http_port, p.https_port, p.p2p_port, p.ui_port);
let allocated = ports::allocate_lane_ports(
base,
expected_env_path.as_deref(),
state_for_allocation.as_ref(),
)
.with_context(|| format!("allocate client-node lane ports for {}", p.name))?;
Ok(Some(allocated))
})
.collect::<Result<Vec<_>>>()?;
let hosts: Vec<Box<dyn DaemonHost>> = profiles
.iter()
.zip(&port_overrides)
.map(|(p, port_override)| {
host::for_mode(mode.clone(), p.clone(), None, None, None, serve_client_node, *port_override)
})
.collect();
let standalone_sources = module_app_dirs(&monorepo_path);
let pre_start_dirs: Vec<std::path::PathBuf> =
hosts.iter().filter_map(|h| h.pre_start_dev_dir()).collect();
if !pre_start_dirs.is_empty() {
dev::platform::stage_standalone_manifests(&standalone_sources, &pre_start_dirs, None)
.context("stage standalone manifests before daemon boot")?;
}
let mut handles = Vec::new();
for h in &hosts {
handles.push(h.ensure_running().context("start platform daemon")?);
}
let dev_dirs: Vec<std::path::PathBuf> = handles.iter().map(|h| h.dev_dir.clone()).collect();
dev::platform::stage_standalone_manifests(&standalone_sources, &dev_dirs, None)
.context("stage standalone manifests")?;
let mut spawned_standalones =
dev::platform::spawn_standalones(&standalone_sources, &handles, &[], None);
if !spawned_standalones.is_empty() {
eprintln!(
"✓ spawned {} standalone app process(es): {}",
spawned_standalones.len(),
spawned_standalones
.iter()
.map(|s| s.label.as_str())
.collect::<Vec<_>>()
.join(", "),
);
}
let onboarding_failures = dev::agent::run_agent_setup(&handles, None, true);
if !onboarding_failures.is_empty() {
let detail = onboarding_failures
.iter()
.map(|(instance, error)| format!(" {instance}: {error}"))
.collect::<Vec<_>>()
.join("\n");
anyhow::bail!(
"onboarding failed — the harness cannot authenticate to the nodes it \
just started:\n{detail}\n\n\
A `No provider registered for capability '…'` message above means the \
daemon came up without that capability's app. Two causes, in order of \
likelihood:\n\
1. `modules/` is missing the extracted node-app-* checkouts (the daemon \
loads them from there via APT_APPS_DIR) — run `make bootstrap-apps`.\n\
2. The app IS in `modules/` but its manifest.json was rejected, so the \
host skipped it. Grep the daemon log for \
`skipping malformed manifest` — the app name, whether it is critical, \
and the parse error are all on that line. Note it logs under the \
`node_app_host` target, so a directive-only RUST_LOG such as \
`node_server=info` hides it; use `info,node_server=info` instead."
);
}
let mut instances = Vec::new();
for (i, profile) in profiles.iter().enumerate() {
let name = profile.name.as_str();
let lane = lane_name(name);
let allocated = port_overrides[i];
let p2p_port = allocated.map(|p| p.p2p).unwrap_or(profile.p2p_port);
let ldk_addr = format!("127.0.0.1:{p2p_port}");
let session = AgentSession::load_for_harness(&monorepo_path, &lane)
.with_context(|| format!("load {lane} agent session"))?;
let dev_dir = crate::commands::dev::host::monorepo::monorepo_dev_dir(
&monorepo_path,
&lane,
None,
)?;
let session_path = AgentSession::file_path(&dev_dir, &lane);
let pid = dev_dir
.parent()
.and_then(|env_dir| std::fs::read_to_string(env_dir.join("daemon.pid")).ok())
.and_then(|s| s.trim().parse::<u32>().ok());
instances.push(InstanceState {
name: name.into(),
session_path,
base_url: session.base_url.clone(),
ldk_addr,
node_id: session.node_id.clone(),
pid,
client_node_ports: allocated,
});
}
let mut state = HarnessState {
created_at: now_iso8601(),
monorepo_path: monorepo_path
.canonicalize()
.unwrap_or_else(|_| monorepo_path.clone()),
bitcoind: BitcoindState {
mode: format!("{bitcoind_mode:?}").to_lowercase(),
rpc_url: btc.rpc_url.clone(),
rpc_user: crate::commands::harness::bitcoind::RPC_USER.into(),
container_id: btc.container_id.clone(),
},
instances,
channel: None,
supervisor_pid: Some(std::process::id()),
client_node_instance: client_node.clone(),
operation_mode,
pwa_dist_built: crate::commands::dev::host::monorepo::pwa_dist_build_outcome(),
};
if with_channel {
let ch = open_channel_flow(&state, &btc, "alice", "bob", 100_000)?;
state.channel = Some(ch);
}
let selected_instance = if operation_mode {
let inst = selected_client_node(&state, client_node.as_deref())?
.ok_or_else(|| anyhow::anyhow!("operation mode requires a client-node instance"))?;
Some((inst.name.clone(), inst.base_url.clone()))
} else {
None
};
let _operation_mode_thread = if let Some((instance_name, _)) = &selected_instance {
let session = AgentSession::load_for_harness(&monorepo_path, &lane_name(instance_name))?;
Some(dev::operation_mode::prepare(session)?.spawn(None))
} else {
None
};
let path = state.save()?;
println!("{}", serde_json::to_string_pretty(&state)?);
let client_node_line = match &selected_instance {
Some((name, base_url)) => format!(" Client-node PWA ({name}): {base_url}."),
None => String::new(),
};
eprintln!(
"HARNESS READY — supervising alice+bob; state at {}.{client_node_line} \
Send SIGTERM (or `node-app harness down`) to stop.",
path.display()
);
while !dev::shutdown_requested() {
std::thread::sleep(Duration::from_millis(500));
}
eprintln!("harness: shutdown signal received — stopping alice+bob…");
for s in spawned_standalones.iter_mut() {
eprintln!("harness: killing standalone {}", s.label);
let _ = s.child.kill();
let _ = s.child.wait();
}
for h in &hosts {
h.shutdown();
}
let _ = btc.stop();
eprintln!("harness: teardown complete.");
Ok(())
}
pub fn open_channel_flow(
state: &HarnessState,
btc: &Bitcoind,
from: &str,
to: &str,
sats: u64,
) -> Result<ChannelState> {
let from_i = state.instance(from)?;
let to_i = state.instance(to)?;
let from_tok = AgentSession::read_token(&from_i.session_path)?;
let client = AgentHttpClient::with_session(from_i.base_url.clone(), from_i.session_path.clone());
eprintln!("harness: funding {from} on-chain (1 BTC)…");
let addr = client.new_onchain_address(&from_tok)?;
btc.send_to_address(&addr, 1.0)?;
eprintln!("harness: mining 6 confirmation blocks…");
btc.mine(6)?;
std::thread::sleep(Duration::from_secs(3));
let peer_str = format!("{}@{}", to_i.node_id, to_i.ldk_addr);
eprintln!("harness: connecting {from} → {to} ({peer_str})…");
client.connect_peer(&from_tok, &peer_str)?;
let push_msat = (sats / 10) * 1_000;
eprintln!(
"harness: opening channel {from}→{to}: {sats} sats, pushing {} sats to {to}…",
push_msat / 1_000
);
client.open_channel(&from_tok, &peer_str, sats, push_msat)?;
eprintln!("harness: mining 6 channel confirmation blocks…");
btc.mine(6)?;
std::thread::sleep(Duration::from_secs(5));
eprintln!("harness: waiting for channel to become usable (120s deadline)…");
let deadline = Instant::now() + Duration::from_secs(120);
loop {
let channels = client.list_channels(&from_tok)?;
let is_usable = if let Some(arr) = channels.as_array() {
arr.iter().any(|ch| {
ch.get("is_usable").and_then(|v| v.as_bool()).unwrap_or(false)
|| ch.get("is_channel_ready").and_then(|v| v.as_bool()).unwrap_or(false)
})
} else {
false
};
if is_usable {
eprintln!("harness: channel {from}→{to} is usable");
return Ok(ChannelState {
from: from.into(),
to: to.into(),
capacity_sats: sats,
status: "usable".into(),
});
}
if Instant::now() >= deadline {
anyhow::bail!(
"channel {from}→{to} not usable within 120s; last: {channels}"
);
}
let _ = btc.mine(1);
std::thread::sleep(Duration::from_secs(2));
}
}
const CRITICAL_APPS: [&str; 3] = ["device-registry", "core-storage", "ldk-node"];
fn preflight_apps(monorepo_path: &std::path::Path) -> Result<()> {
let modules_dir = monorepo_path.join("modules");
let missing: Vec<&str> = CRITICAL_APPS
.iter()
.copied()
.filter(|app| !modules_dir.join(app).join("manifest.json").is_file())
.collect();
if !missing.is_empty() {
let ldk_note = if missing.contains(&"ldk-node") {
"\nNote: modules/ldk-node is not built by `make bootstrap-apps` alone — after it \
clones the repo, also run `make build-ldk-node` (features: swaps,cycles)."
} else {
""
};
anyhow::bail!(
"missing app manifest for: {} (looked under {}).\n\
The daemon loads its apps from this directory, and without these it \
cannot onboard — every harness probe would fail.\n\
Run `make bootstrap-apps` to clone + build the extracted node-app-* \
repos, then retry.{}\n\
Note this preflight only checks that manifest.json EXISTS. A manifest \
that is present but unparseable passes here and is dropped later by the \
host, surfacing as `No provider registered for capability '…'` during \
onboarding — see `skipping malformed manifest` in the daemon log.",
missing.join(", "),
modules_dir.display(),
ldk_note,
);
}
for env_file_name in ["alice.env", "bob.env"] {
let base_env_path = monorepo_path.join("system/server").join(env_file_name);
if !base_env_path.exists() {
crate::commands::dev::host::monorepo::materialize_base_env_from_example(
&base_env_path,
)
.with_context(|| {
format!(
"preflight: no base env file at {} and generating one from its \
.env.example template failed",
base_env_path.display()
)
})?;
}
}
Ok(())
}
#[cfg(unix)]
fn remove_stale_lock_for_cwd() {
let Ok(cwd) = std::env::current_dir() else {
return;
};
let Ok(path) = crate::commands::harness::state::lock_path(&cwd) else {
return;
};
let holder = std::fs::read_to_string(&path)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok());
match holder {
Some(pid) if crate::commands::harness::state::pid_is_alive(pid) => {}
_ => {
let _ = std::fs::remove_file(&path);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OrphanedDaemon {
pub(crate) pid: u32,
pub(crate) env_path: std::path::PathBuf,
}
fn discover_orphaned_daemons() -> Vec<OrphanedDaemon> {
let Ok(output) = std::process::Command::new("ps").args(["-eo", "pid=,command="]).output() else {
return Vec::new();
};
let listing = String::from_utf8_lossy(&output.stdout);
parse_orphaned_daemons(&listing)
}
fn this_checkouts_daemon_env_paths(monorepo_path: &Path) -> Vec<PathBuf> {
["alice", "bob"]
.iter()
.flat_map(|base| {
[false, true]
.into_iter()
.map(move |client_node| {
crate::commands::dev::host::monorepo::lane_instance_name(base, client_node)
})
})
.filter_map(|instance| {
crate::commands::dev::host::monorepo::monorepo_dev_dir(monorepo_path, &instance, None)
.ok()
.and_then(|dev_dir| dev_dir.parent().map(|env_dir| env_dir.join("daemon.env")))
})
.collect()
}
fn partition_orphaned_daemons(
all: Vec<OrphanedDaemon>,
monorepo_path: &Path,
) -> (Vec<OrphanedDaemon>, Vec<OrphanedDaemon>) {
let ours_paths = this_checkouts_daemon_env_paths(monorepo_path);
all.into_iter().partition(|d| ours_paths.contains(&d.env_path))
}
fn parse_orphaned_daemons(ps_listing: &str) -> Vec<OrphanedDaemon> {
let mut found = Vec::new();
for line in ps_listing.lines() {
let line = line.trim_start();
let Some((pid_str, command)) = line.split_once(char::is_whitespace) else {
continue;
};
let Ok(pid) = pid_str.parse::<u32>() else {
continue;
};
let argv0 = command.split_whitespace().next().unwrap_or("");
if !argv0.ends_with("node-server") {
continue;
}
let Some(rest) = command.split("--env ").nth(1) else {
continue;
};
let env_path = rest.split_whitespace().next().unwrap_or("");
if !(env_path.contains("node-app/")
&& env_path.contains("/monorepo-")
&& env_path.ends_with("daemon.env"))
{
continue;
}
found.push(OrphanedDaemon { pid, env_path: std::path::PathBuf::from(env_path) });
}
found
}
fn kill_recorded_daemons(state: &HarnessState) {
for inst in &state.instances {
let Some(instance_root) = inst.session_path.parent().and_then(|p| p.parent()) else {
continue;
};
let env_arg = instance_root.join("daemon.env");
let _ = std::process::Command::new("pkill")
.args(["-f", &format!("node-server --env {}", env_arg.display())])
.status();
}
}
fn now_iso8601() -> String {
chrono::Utc::now().to_rfc3339()
}
#[derive(Debug, Serialize)]
struct CheckResult {
ok: bool,
severity: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
impl CheckResult {
fn from_result(result: Result<serde_json::Value>) -> Self {
match result {
Ok(data) => Self { ok: true, severity: "ok", data: Some(data), error: None },
Err(err) => {
let severity = match err.downcast_ref::<ProbeError>() {
Some(ProbeError::Unauthorized { .. }) => "auth_failed",
Some(ProbeError::Unreachable { .. }) => "unreachable",
Some(ProbeError::Http { .. }) => "http_error",
Some(ProbeError::Protocol { .. }) => "protocol_error",
None => "error",
};
Self { ok: false, severity, data: None, error: Some(format!("{err:#}")) }
}
}
}
}
pub fn status() -> Result<()> {
let state = load_state()?;
let mut instances = serde_json::Map::new();
let mut all_healthy = true;
let mut failures: Vec<String> = Vec::new();
for inst in &state.instances {
let token = AgentSession::read_token(&inst.session_path)
.with_context(|| format!("read token for {}", inst.name))?;
let client = AgentHttpClient::with_session(inst.base_url.clone(), inst.session_path.clone());
let checks: [(&str, CheckResult); 3] = [
("balance", CheckResult::from_result(client.get_balance(&token))),
("channels", CheckResult::from_result(client.list_channels(&token))),
("peers", CheckResult::from_result(client.list_peers(&token))),
];
let instance_healthy = checks.iter().all(|(_, c)| c.ok);
if !instance_healthy {
all_healthy = false;
for (name, check) in &checks {
if !check.ok {
failures.push(format!(
"{}.{name} [{}]: {}",
inst.name,
check.severity,
check.error.as_deref().unwrap_or("unknown error"),
));
}
}
}
let mut checks_map = serde_json::Map::new();
for (name, check) in checks {
checks_map.insert(name.to_string(), serde_json::to_value(check)?);
}
instances.insert(inst.name.clone(), json!({
"healthy": instance_healthy,
"checks": serde_json::Value::Object(checks_map),
}));
}
let report = json!({
"healthy": all_healthy,
"instances": serde_json::Value::Object(instances),
});
println!("{}", serde_json::to_string_pretty(&report)?);
if all_healthy {
Ok(())
} else {
anyhow::bail!(
"harness status: {} check(s) failed:\n {}",
failures.len(),
failures.join("\n "),
);
}
}
pub fn mine(blocks: u32) -> Result<()> {
let state = load_state()?;
let btc = Bitcoind::from_state(&state.bitcoind);
btc.mine(blocks)?;
let height = btc.block_count()?;
println!("{}", json!({ "mined": blocks, "height": height }));
Ok(())
}
pub fn fund(node: &str, btc_amount: f64) -> Result<()> {
let state = load_state()?;
let inst = state.instance(node)?;
let token = AgentSession::read_token(&inst.session_path)
.with_context(|| format!("read token for {node}"))?;
let client = AgentHttpClient::with_session(inst.base_url.clone(), inst.session_path.clone());
let addr = client.new_onchain_address(&token)?;
let btc_handle = Bitcoind::from_state(&state.bitcoind);
let txid = btc_handle.send_to_address(&addr, btc_amount)?;
btc_handle.mine(6)?;
println!("{}", json!({
"address": addr,
"txid": txid,
"confirmations": 6,
}));
Ok(())
}
pub fn channel_open(from: &str, to: &str, sats: u64) -> Result<()> {
let mut state = load_state()?;
let btc = Bitcoind::from_state(&state.bitcoind);
let ch = open_channel_flow(&state, &btc, from, to, sats)?;
let ch_json = serde_json::to_value(&ch)?;
state.channel = Some(ch);
state.save()?;
println!("{}", serde_json::to_string_pretty(&ch_json)?);
Ok(())
}
pub fn invoke(node: &str, capability: &str, payload_str: &str) -> Result<()> {
let state = load_state()?;
let inst = state.instance(node)?;
let token = AgentSession::read_token(&inst.session_path)
.with_context(|| format!("read token for {node}"))?;
let client = AgentHttpClient::with_session(inst.base_url.clone(), inst.session_path.clone());
let payload: serde_json::Value = serde_json::from_str(payload_str)
.with_context(|| format!("parse payload JSON: {payload_str}"))?;
let response = client.invoke_capability(&token, capability, payload)?;
println!("{}", serde_json::to_string_pretty(&response)?);
Ok(())
}
#[derive(Debug)]
struct PairBrowserOutput {
base_url: String,
browser_origin: String,
owner_token: String,
snippet: String,
device_name: String,
}
fn instance_root(inst: &InstanceState) -> Option<&Path> {
inst.session_path.parent().and_then(|p| p.parent())
}
fn tls_manifest_ready(instance_root: &Path) -> bool {
let Ok(contents) = std::fs::read_to_string(instance_root.join("tls.json")) else {
return false;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) else {
return false;
};
value.get("state").and_then(|s| s.as_str()) == Some("ready")
}
fn https_port_from_daemon_env(instance_root: &Path) -> Option<u16> {
let contents = std::fs::read_to_string(instance_root.join("daemon.env")).ok()?;
contents.lines().find_map(|line| {
let (key, value) = line.split_once('=')?;
if key.trim() != "HTTPS_SERVER_ADDRESS" {
return None;
}
let (_, port) = value.trim().rsplit_once(':')?;
port.trim().parse::<u16>().ok()
})
}
fn browser_origin(inst: &InstanceState) -> String {
if let Some(root) = instance_root(inst) {
if tls_manifest_ready(root) {
if let Some(port) = https_port_from_daemon_env(root) {
return format!("https://127.0.0.1:{port}");
}
}
}
match &inst.client_node_ports {
Some(ports) => format!("https://127.0.0.1:{}", ports.https),
None => inst.base_url.clone(),
}
}
const PAIR_BROWSER_DEVICE_NAME: &str = "harness-browser";
static PAIR_BROWSER_DEVICE_COUNTER: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
fn unique_device_name() -> String {
let counter = PAIR_BROWSER_DEVICE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!(
"{PAIR_BROWSER_DEVICE_NAME}-{}-{}-{counter}",
std::process::id(),
chrono::Utc::now().timestamp_millis(),
)
}
fn ensure_live_owner_token(inst: &InstanceState, cached_token: &str) -> Result<String> {
let client = AgentHttpClient::with_session(inst.base_url.clone(), inst.session_path.clone());
match client.get_node_id(cached_token) {
Ok(_) => AgentSession::read_token(&inst.session_path).with_context(|| {
format!(
"re-read {}'s token from {} after validating it",
inst.name,
inst.session_path.display()
)
}),
Err(e) => Err(anyhow::anyhow!(
"harness pair-browser: attempted to validate '{node}'s cached owner token via a \
live GET /api/node/info call (auto-refreshing once on a 401); observed: {e:#}. The \
harness session credentials for '{node}' are dead — most likely the daemon \
restarted since this session was minted, which invalidates its refresh token \
server-side even though the on-disk session file at {session_path} still looks \
valid. Refusing to print a snippet carrying a token that could not be proven live. \
Remedy: re-onboard this instance with `node-app harness up --clean` (bring the \
stack down and back up fresh), then retry `node-app harness pair-browser {node}`.",
node = inst.name,
session_path = inst.session_path.display(),
)),
}
}
fn build_pair_browser_output(state: &HarnessState, node: &str) -> Result<PairBrowserOutput> {
let inst = state.instance(node)?;
let cached_token = AgentSession::read_token(&inst.session_path)
.with_context(|| format!("read token for {node}"))?;
let token = ensure_live_owner_token(inst, &cached_token)?;
let device_name = unique_device_name();
let origin = browser_origin(inst);
let snippet =
crate::commands::harness::snippet::render(&inst.node_id, &origin, &token, &device_name);
Ok(PairBrowserOutput {
base_url: inst.base_url.clone(),
browser_origin: origin,
owner_token: token,
snippet,
device_name,
})
}
fn no_https_listener_warning(node: &str, base_url: &str) -> String {
format!(
"harness: WARNING — no live HTTPS listener was found for '{node}' (checked its TLS \
state manifest for a ready certificate, and found no `--client-node` HTTPS lane \
either). The PWA kernel does NOT activate over plain HTTP — its boot-gate trust \
probe (GET /.well-known/client-node-origin) requires a real HTTPS context — so \
pasting this snippet at {base_url} is expected to fail the origin guard. Verified \
remedy: bring this instance up with `harness up --client-node {node}`, which \
provisions a real self-signed certificate for it. Provisioning TLS onto an \
already-running, non-`--client-node` instance may also be possible (this harness has \
observed that exact state on a live instance before), but no supported way to trigger \
it from here is confirmed — treat that as unconfirmed, not a fix to rely on."
)
}
pub fn pair_browser(node: &str, json: bool) -> Result<()> {
let state = load_state()?;
let output = build_pair_browser_output(&state, node)?;
eprintln!(
"harness: pairing snippet for '{node}' — daemon API at {}",
output.base_url
);
if output.browser_origin == output.base_url {
eprintln!("{}", no_https_listener_warning(node, &output.base_url));
} else {
eprintln!(
"harness: open devtools at that EXACT origin ({}) — over HTTPS; the PWA kernel does \
not activate over plain HTTP — and paste the snippet into the console",
output.browser_origin
);
}
eprintln!(
"harness: WARNING — this snippet embeds a LIVE owner bearer token. Do not save it to a \
file, paste it into a shared channel, or commit it anywhere."
);
if json {
let payload = serde_json::json!({
"base_url": output.base_url,
"browser_origin": output.browser_origin,
"owner_token": output.owner_token,
"snippet": output.snippet,
"device_name": output.device_name,
});
println!("{}", serde_json::to_string_pretty(&payload)?);
} else {
println!("{}", output.snippet);
}
Ok(())
}
pub fn logs(node: &str, tail: bool) -> Result<()> {
let state = load_state()?;
let inst = state.instance(node)?;
let instance_root = inst.session_path
.parent() .and_then(|p| p.parent()) .ok_or_else(|| anyhow::anyhow!("session_path has no grandparent"))?;
let log_path = instance_root.join("daemon.log");
if !log_path.exists() {
anyhow::bail!("log not found: {}", log_path.display());
}
let content = std::fs::read_to_string(&log_path)
.with_context(|| format!("read {}", log_path.display()))?;
if tail {
let lines: Vec<&str> = content.lines().collect();
let start = lines.len().saturating_sub(200);
for line in &lines[start..] {
println!("{}", line);
}
} else {
print!("{}", content);
}
Ok(())
}
pub fn down(clean: bool) -> Result<()> {
let state = match HarnessState::load() {
Ok(s) => s,
Err(_) => {
remove_stale_lock_for_cwd();
let cwd = std::env::current_dir().context("resolve current directory")?;
let all = discover_orphaned_daemons();
let (orphans, foreign) = partition_orphaned_daemons(all, &cwd);
if !foreign.is_empty() {
eprintln!(
"harness down: {} daemon-shaped process(es) look harness-generated but do \
not belong to this checkout ({}) — left alone, not terminated:\n{}",
foreign.len(),
cwd.display(),
foreign
.iter()
.map(|d| format!(" pid {} env {}", d.pid, d.env_path.display()))
.collect::<Vec<_>>()
.join("\n"),
);
}
if orphans.is_empty() {
println!("{}", json!({ "stopped": false, "reason": "no running harness" }));
return Ok(());
}
eprintln!(
"harness down: no state file, but {} orphaned daemon(s) belonging to this \
checkout are still running — stopping them",
orphans.len(),
);
for orphan in &orphans {
eprintln!("harness down: SIGTERM {} ({})", orphan.pid, orphan.env_path.display());
#[cfg(unix)]
unsafe {
libc::kill(orphan.pid as libc::pid_t, libc::SIGTERM);
}
}
let deadline = Instant::now() + Duration::from_secs(40);
loop {
std::thread::sleep(Duration::from_millis(300));
let alive: Vec<&OrphanedDaemon> = orphans
.iter()
.filter(|o| crate::commands::harness::state::pid_is_alive(o.pid))
.collect();
if alive.is_empty() {
break;
}
if Instant::now() >= deadline {
for o in alive {
eprintln!("harness down: {} did not exit within 40s — SIGKILL", o.pid);
#[cfg(unix)]
unsafe {
libc::kill(o.pid as libc::pid_t, libc::SIGKILL);
}
}
break;
}
}
let _ = std::process::Command::new("docker")
.args(["rm", "-f", crate::commands::harness::bitcoind::CONTAINER_NAME])
.output();
println!(
"{}",
json!({
"stopped": true,
"reason": "recovered orphaned daemons (no state file)",
"daemons": orphans.iter().map(|o| o.pid).collect::<Vec<_>>(),
}),
);
return Ok(());
}
};
#[cfg(unix)]
{
if let Some(pid) = state.supervisor_pid {
eprintln!("harness down: sending SIGTERM to supervisor PID {pid}");
let rc = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
if rc != 0 {
let still_alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
if !still_alive {
eprintln!("harness down: supervisor {pid} is no longer running");
} else {
eprintln!("harness down: kill({pid}, SIGTERM) returned non-zero");
}
} else {
let deadline = Instant::now() + Duration::from_secs(40);
let mut timed_out = false;
loop {
std::thread::sleep(Duration::from_millis(300));
let probe = unsafe { libc::kill(pid as libc::pid_t, 0) };
if probe != 0 {
break;
}
if Instant::now() >= deadline {
eprintln!(
"harness down: supervisor {pid} did not exit within 40s — \
escalating to SIGKILL"
);
timed_out = true;
break;
}
}
if timed_out {
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
kill_recorded_daemons(&state);
}
}
} else {
eprintln!("harness down: no supervisor_pid in state, falling back to best-effort teardown");
kill_recorded_daemons(&state);
}
}
if let Some(id) = &state.bitcoind.container_id {
let _ = std::process::Command::new("docker").args(["stop", id]).output();
}
if clean {
let cache_root = state_path(&state.monorepo_path)
.ok()
.and_then(|p| {
let mut cur = p.parent()?.to_path_buf();
loop {
if cur.file_name().map(|n| n == "node-app").unwrap_or(false) {
return Some(cur);
}
if !cur.pop() {
return None;
}
}
});
for inst in &state.instances {
let instance_root = match inst.session_path
.parent() .and_then(|p| p.parent()) {
Some(d) => d.to_path_buf(),
None => continue,
};
let instance_root = instance_root.canonicalize().unwrap_or(instance_root);
let under_cache = cache_root.as_ref().map(|root| instance_root.starts_with(root)).unwrap_or(false);
if !under_cache {
eprintln!(
"harness down: skipping clean for {} (not under cache root)",
instance_root.display()
);
continue;
}
for name in ["dev.db", "dev.db-shm", "dev.db-wal", "lightning.db"] {
let _ = std::fs::remove_file(instance_root.join(name));
}
for name in ["ldk_data", "ldk_node_data", "ldk_node_data_backup"] {
let p = instance_root.join(name);
if p.is_dir() {
let _ = std::fs::remove_dir_all(&p);
}
}
}
}
let _ = std::fs::remove_file(resolve_state_path()?);
println!("{}", json!({ "stopped": true, "cleaned": clean }));
Ok(())
}
pub fn pay(from: &str, to: &str, sats: u64) -> Result<()> {
let state = load_state()?;
let from_i = state.instance(from)?;
let to_i = state.instance(to)?;
let from_tok = AgentSession::read_token(&from_i.session_path)
.with_context(|| format!("read token for {from}"))?;
let to_tok = AgentSession::read_token(&to_i.session_path)
.with_context(|| format!("read token for {to}"))?;
let from_client = AgentHttpClient::with_session(from_i.base_url.clone(), from_i.session_path.clone());
let to_client = AgentHttpClient::with_session(to_i.base_url.clone(), to_i.session_path.clone());
let bolt11 = to_client
.create_invoice(&to_tok, sats * 1_000, "harness pay")
.with_context(|| format!("{to} create_invoice failed"))?;
let pay_resp = from_client
.pay_invoice(&from_tok, &bolt11)
.map_err(|e| {
let msg = e.to_string();
if msg.to_lowercase().contains("route")
|| msg.to_lowercase().contains("channel")
|| msg.to_lowercase().contains("liquidity")
|| msg.to_lowercase().contains("no path")
{
anyhow::anyhow!(
"payment failed — no usable route/channel: {msg}\n\
hint: run `node-app harness channel-open {from} {to} 100000` first"
)
} else {
anyhow::anyhow!("pay_invoice failed: {msg}")
}
})?;
let payment_hash = pay_resp
.get("payment_hash")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let initial_status = pay_resp
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let initial_preimage = pay_resp
.get("preimage")
.and_then(|v| v.as_str())
.map(str::to_owned);
let (final_status, final_preimage) = if initial_status == "pending"
&& !payment_hash.is_empty()
{
eprintln!("harness pay: payment pending — polling status (30s deadline)…");
let deadline = Instant::now() + Duration::from_secs(30);
let mut status = initial_status.clone();
let mut preimage = initial_preimage.clone();
loop {
std::thread::sleep(Duration::from_millis(500));
match from_client.invoke_capability(
&from_tok,
"core.lightning.get_payment_status",
json!({ "payment_hash": payment_hash }),
) {
Ok(v) => {
status = v
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("unknown")
.to_string();
preimage = v
.get("preimage")
.and_then(|p| p.as_str())
.map(str::to_owned);
if status == "succeeded" || status == "failed" {
break;
}
}
Err(e) => {
eprintln!("harness pay: get_payment_status error: {e}");
}
}
if Instant::now() >= deadline {
eprintln!("harness pay: payment still pending after 30s");
break;
}
}
(status, preimage)
} else {
(initial_status, initial_preimage)
};
println!(
"{}",
serde_json::to_string_pretty(&json!({
"invoice": bolt11,
"status": final_status,
"payment_hash": payment_hash,
"preimage": final_preimage,
}))?
);
if !matches!(final_status.as_str(), "succeeded" | "settled") {
anyhow::bail!(
"payment did not settle (status: {final_status}); \
check that a usable channel exists: `node-app harness channel-open {from} {to} 100000`"
);
}
Ok(())
}
pub fn l402(from: &str, to: &str, route: &str, payload_str: &str) -> Result<()> {
let state = load_state()?;
let from_i = state.instance(from)?;
let _to_i = state.instance(to)?;
let from_tok = AgentSession::read_token(&from_i.session_path)
.with_context(|| format!("read token for {from}"))?;
let payload: serde_json::Value = serde_json::from_str(payload_str)
.with_context(|| format!("parse payload JSON: {payload_str}"))?;
let mut payload = payload;
if let Some(obj) = payload.as_object_mut() {
obj.entry("execution_preference".to_string())
.or_insert(serde_json::Value::String("remote".to_string()));
}
let from_client = AgentHttpClient::new(from_i.base_url.clone());
let (status_code, body) = from_client
.post_raw_with_status(&from_tok, route, &payload)
.with_context(|| format!("POST {route} on {from}"))?;
if status_code == 200 {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"route": route,
"settled": true,
"note": "settled=true means the remote route returned 200; \
in a dev env with no paid route configured the daemon \
may handle locally",
"response": body,
}))?
);
Ok(())
} else {
let hint = if status_code == 402 {
format!(
"received 402 — no channel or budget / no paid route configured \
between {from} and {to}; \
open a channel first: `node-app harness channel-open {from} {to} 100000`"
)
} else {
format!(
"route returned HTTP {status_code} (may be unconfigured or require \
additional setup)"
)
};
println!(
"{}",
serde_json::to_string_pretty(&json!({
"route": route,
"settled": false,
"http_status": status_code,
"hint": hint,
"body": body,
}))?
);
anyhow::bail!("l402 probe: route {route} did not return 200 (got {status_code})");
}
}
fn load_state() -> Result<HarnessState> {
HarnessState::load().map_err(|_| {
anyhow::anyhow!(
"no harness state found — run `node-app harness up` first"
)
})
}
fn module_app_dirs(monorepo_path: &std::path::Path) -> Vec<std::path::PathBuf> {
let modules = monorepo_path.join("modules");
let Ok(entries) = std::fs::read_dir(&modules) else {
return Vec::new();
};
let mut dirs: Vec<std::path::PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.join("manifest.json").is_file())
.collect();
dirs.sort();
dirs
}
fn selected_client_node<'a>(
state: &'a HarnessState,
selected: Option<&str>,
) -> Result<Option<&'a InstanceState>> {
match selected {
None => Ok(None),
Some(name) => state.instance(name).map(Some),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::harness::state::BitcoindState;
use crate::commands::dev::agent::session::AgentSession;
use crate::commands::dev::host::monorepo::{lane_instance_name, monorepo_dev_dir};
#[test]
fn client_node_lane_resolves_a_different_session_path_than_the_base_instance() {
let monorepo = std::path::Path::new("/tmp/monorepo");
let base = lane_instance_name("alice", false);
let lane = lane_instance_name("alice", true);
assert_eq!(base, "alice");
assert_eq!(lane, "alice-pwa");
let base_dir = monorepo_dev_dir(monorepo, &base, None).expect("base dev dir");
let lane_dir = monorepo_dev_dir(monorepo, &lane, None).expect("lane dev dir");
assert_ne!(
base_dir, lane_dir,
"client-node lane must get its own instance root, not share the base instance's",
);
let base_session = AgentSession::file_path(&base_dir, &base);
let lane_session = AgentSession::file_path(&lane_dir, &lane);
assert_ne!(base_session, lane_session);
assert_eq!(
lane_session.file_name().and_then(|n| n.to_str()),
Some("alice-pwa-agent-session.json"),
);
}
#[test]
fn instance_state_keeps_the_base_name_in_client_node_mode() {
let harness_state = client_node_state();
let selected = selected_client_node(&harness_state, Some("alice"))
.expect("selection")
.expect("some instance");
assert_eq!(selected.name, "alice");
assert_eq!(
lane_instance_name(&selected.name, true),
"alice-pwa",
"the lane label is derived on demand, never stored in InstanceState",
);
}
#[test]
fn orphan_discovery_matches_harness_daemons_only() {
let listing = "\
36775 ./target/debug/node-server --env /Users/x/.cache/node-app/monorepo-9c9913da/daemon.env
40746 ./target/debug/node-server --env /Users/x/.cache/node-app/monorepo-82b77b5f/daemon.env
";
let found = parse_orphaned_daemons(listing);
assert_eq!(found.len(), 2);
assert_eq!(found[0].pid, 36775);
assert_eq!(found[1].pid, 40746);
assert!(found[0].env_path.ends_with("daemon.env"));
}
#[test]
fn orphan_discovery_skips_foreign_and_launcher_processes() {
let listing = "\
111 /usr/bin/node-server --env /etc/node/daemon.env
222 /bin/zsh -c node-app harness down --clean
333 /usr/lib/node/node-server
444 ugrep node-server --env /Users/x/.cache/node-app/monorepo-1/daemon.env
";
let found = parse_orphaned_daemons(listing);
let pids: Vec<u32> = found.iter().map(|o| o.pid).collect();
assert!(!pids.contains(&111), "packaged install must not be swept up");
assert!(!pids.contains(&222), "the launcher shell must not be swept up");
assert!(!pids.contains(&333), "a daemon with no --env is not ours to judge");
assert!(!pids.contains(&444), "a `harness down` command line must not match itself");
}
#[test]
fn partition_orphaned_daemons_leaves_a_different_checkouts_daemon_alone() {
let mine = std::path::Path::new("/tmp/checkout-mine");
let theirs = std::path::Path::new("/tmp/checkout-theirs");
let mine_env = monorepo_dev_dir(mine, &lane_instance_name("alice", false), None)
.unwrap()
.parent()
.unwrap()
.join("daemon.env");
let theirs_env = monorepo_dev_dir(theirs, &lane_instance_name("bob", true), None)
.unwrap()
.parent()
.unwrap()
.join("daemon.env");
let all = vec![
OrphanedDaemon { pid: 111, env_path: mine_env },
OrphanedDaemon { pid: 222, env_path: theirs_env },
];
let (ours, foreign) = partition_orphaned_daemons(all, mine);
assert_eq!(ours.len(), 1);
assert_eq!(ours[0].pid, 111);
assert_eq!(
foreign.len(),
1,
);
assert_eq!(
foreign[0].pid, 222,
"a different checkout's daemon must never be selected for termination"
);
}
#[test]
fn this_checkouts_daemon_env_paths_covers_every_instance_and_lane() {
let monorepo = std::path::Path::new("/tmp/some-checkout");
let paths = this_checkouts_daemon_env_paths(monorepo);
assert_eq!(paths.len(), 4, "alice/bob x plain/client-node = 4 possible env files");
for p in &paths {
assert!(p.ends_with("daemon.env"));
}
let mut sorted = paths.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), 4);
}
fn client_node_state() -> HarnessState {
let mut s = state();
s.client_node_instance = Some("alice".into());
s
}
fn state() -> HarnessState {
HarnessState {
created_at: "2026-07-01T00:00:00Z".into(),
monorepo_path: "/tmp/monorepo".into(),
bitcoind: BitcoindState {
mode: "docker".into(),
rpc_url: "http://127.0.0.1:18443".into(),
rpc_user: "polaruser".into(),
container_id: None,
},
instances: vec![
InstanceState {
name: "alice".into(),
session_path: "/tmp/alice-agent-session.json".into(),
base_url: "http://127.0.0.1:3001".into(),
ldk_addr: "127.0.0.1:9937".into(),
node_id: "03aa".into(),
pid: None,
client_node_ports: None,
},
InstanceState {
name: "bob".into(),
session_path: "/tmp/bob-agent-session.json".into(),
base_url: "http://127.0.0.1:3002".into(),
ldk_addr: "127.0.0.1:9938".into(),
node_id: "03bb".into(),
pid: None,
client_node_ports: None,
},
],
channel: None,
supervisor_pid: None,
client_node_instance: None,
operation_mode: false,
pwa_dist_built: None,
}
}
#[test]
fn no_client_node_selection_returns_none() {
assert!(selected_client_node(&state(), None).unwrap().is_none());
}
#[test]
fn alice_selection_returns_alice() {
let harness_state = state();
let selected = selected_client_node(&harness_state, Some("alice"))
.unwrap()
.unwrap();
assert_eq!(selected.name, "alice");
}
#[test]
fn unknown_selection_fails_closed() {
let harness_state = state();
let error = selected_client_node(&harness_state, Some("carol")).unwrap_err();
assert!(error.to_string().contains("unknown instance"));
}
fn instance_with_root(name: &str, root: &Path, base_url: &str) -> InstanceState {
InstanceState {
name: name.into(),
session_path: root.join("dev-apps").join(format!("{name}-agent-session.json")),
base_url: base_url.into(),
ldk_addr: "127.0.0.1:9937".into(),
node_id: "03aa".into(),
pid: None,
client_node_ports: None,
}
}
#[test]
fn browser_origin_reports_the_https_origin_for_a_tls_ready_instance_without_a_client_node_lane()
{
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("daemon.env"),
"SERVER_ADDRESS=0.0.0.0:3001\nHTTPS_SERVER_ADDRESS=0.0.0.0:4431\n",
)
.expect("write daemon.env");
std::fs::write(
dir.path().join("tls.json"),
r#"{"schema_version":1,"state":"ready","browser_redirect_enabled":true,"certificate_trusted":false}"#,
)
.expect("write tls.json");
let inst = instance_with_root("alice", dir.path(), "http://127.0.0.1:3001");
assert!(inst.client_node_ports.is_none());
assert_eq!(browser_origin(&inst), "https://127.0.0.1:4431");
}
#[test]
fn browser_origin_falls_back_to_the_allocated_https_lane_port_without_a_tls_manifest() {
let dir = tempfile::tempdir().expect("tempdir");
let mut inst = instance_with_root("alice", dir.path(), "http://127.0.0.1:3001");
inst.client_node_ports = Some(ports::PortSet { http: 3301, https: 4431, p2p: 4331, ui: 3300 });
assert_eq!(browser_origin(&inst), "https://127.0.0.1:4431");
}
#[test]
fn browser_origin_falls_back_to_plain_http_base_url_without_any_tls_signal() {
let dir = tempfile::tempdir().expect("tempdir");
let inst = instance_with_root("alice", dir.path(), "http://127.0.0.1:3001");
assert!(inst.client_node_ports.is_none());
assert_eq!(browser_origin(&inst), inst.base_url);
}
#[test]
fn no_https_listener_warning_does_not_overclaim() {
let msg = no_https_listener_warning("alice", "http://127.0.0.1:3001");
assert!(
msg.contains("no live HTTPS listener"),
"must state the no-HTTPS conclusion plainly: {msg}"
);
assert!(
msg.contains("harness up --client-node alice"),
"must name the one verified remedy: {msg}"
);
assert!(
msg.contains("unconfirmed"),
"the non-`--client-node` TLS-provisioning path must be labeled unconfirmed, not \
asserted as a working remedy: {msg}"
);
assert!(
!msg.contains("will fail"),
"must not assert an unverified certain outcome (\"will fail\") — only \"expected \
to fail\": {msg}"
);
}
#[test]
fn pair_browser_rejects_an_unknown_instance() {
let error = build_pair_browser_output(&state(), "nope").unwrap_err();
assert!(format!("{error:#}").contains("nope"));
}
#[test]
fn pair_browser_reports_a_missing_session_by_name() {
let error = build_pair_browser_output(&state(), "alice").unwrap_err();
assert!(
format!("{error:#}").contains("alice"),
"a missing session file must name the instance it was read for"
);
}
#[test]
fn unique_device_name_keeps_the_prefix_and_differs_between_calls() {
let a = unique_device_name();
let b = unique_device_name();
assert!(
a.starts_with(PAIR_BROWSER_DEVICE_NAME),
"device name must keep the recognizable harness-browser prefix: {a}"
);
assert!(
b.starts_with(PAIR_BROWSER_DEVICE_NAME),
"device name must keep the recognizable harness-browser prefix: {b}"
);
assert_ne!(
a, b,
"two invocations must not collide on the same device_id"
);
}
mod stub_http {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::time::{Duration, Instant};
pub struct Canned {
status: u16,
body: String,
}
pub fn canned(status: u16, body: serde_json::Value) -> Canned {
Canned { status, body: body.to_string() }
}
const DEADLINE: Duration = Duration::from_secs(10);
pub fn spawn(responses: Vec<Canned>) -> (String, mpsc::Receiver<Result<(), String>>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
listener
.set_nonblocking(true)
.expect("set stub listener non-blocking (needed to bound accept())");
let addr = listener.local_addr().expect("stub listener addr");
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let expected = responses.len();
let deadline = Instant::now() + DEADLINE;
for (served, canned) in responses.into_iter().enumerate() {
let mut stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if Instant::now() >= deadline {
let _ = tx.send(Err(format!(
"stub only saw {served}/{expected} scripted \
connections before its own {DEADLINE:?} deadline"
)));
return;
}
std::thread::sleep(Duration::from_millis(20));
}
Err(_) => {
let _ = tx.send(Err(format!(
"stub accept() failed after {served}/{expected} served"
)));
return;
}
}
};
let _ = stream.set_nonblocking(false);
let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
let mut buf = [0u8; 8192];
let _ = stream.read(&mut buf); let reason = match canned.status {
200 => "OK",
401 => "Unauthorized",
_ => "Status",
};
let response = format!(
"HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
canned.status,
reason,
canned.body.len(),
canned.body,
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
let _ = tx.send(Ok(()));
});
(format!("http://{addr}"), rx)
}
pub fn await_done(rx: &mpsc::Receiver<Result<(), String>>) -> Result<(), String> {
match rx.recv_timeout(DEADLINE + Duration::from_secs(2)) {
Ok(result) => result,
Err(_) => Err(format!(
"stub thread never reported an outcome within {:?}",
DEADLINE + Duration::from_secs(2)
)),
}
}
}
fn write_pair_browser_test_session(
dir: &std::path::Path,
token: &str,
refresh_token: &str,
base_url: &str,
) -> PathBuf {
let session = AgentSession {
instance: "alice".into(),
base_url: base_url.into(),
node_id: "03aa".into(),
public_key: "pub".into(),
secret_key_hex: "sec".into(),
mnemonic: "test mnemonic".into(),
token: token.into(),
refresh_token: refresh_token.into(),
onboarded_at: chrono::Utc::now(),
last_login_at: chrono::Utc::now(),
};
session.save(dir).expect("write test session");
AgentSession::file_path(dir, &session.instance)
}
fn pair_browser_test_instance(name: &str, session_path: PathBuf, base_url: String) -> InstanceState {
InstanceState {
name: name.into(),
session_path,
base_url,
ldk_addr: "127.0.0.1:9937".into(),
node_id: "03aa".into(),
pid: None,
client_node_ports: None,
}
}
#[test]
fn ensure_live_owner_token_refreshes_a_stale_token_and_returns_the_fresh_one() {
let dir = tempfile::tempdir().expect("tempdir");
let (base_url, done_rx) = stub_http::spawn(vec![
stub_http::canned(401, json!({ "error": "Invalid or expired token" })),
stub_http::canned(
200,
json!({ "token": "fresh-access-token", "refresh_token": "fresh-refresh-token" }),
),
stub_http::canned(200, json!({ "node_id": "03aa" })),
]);
let session_path =
write_pair_browser_test_session(dir.path(), "stale-token", "still-valid-refresh", &base_url);
let inst = pair_browser_test_instance("alice", session_path.clone(), base_url);
let live = ensure_live_owner_token(&inst, "stale-token")
.expect("a stale token must be transparently refreshed, not fail the call");
stub_http::await_done(&done_rx).expect("stub should have served all 3 scripted responses");
assert_eq!(live, "fresh-access-token");
let persisted = AgentSession::load_from_path(&session_path).expect("reload session");
assert_eq!(
persisted.token, "fresh-access-token",
"the refreshed token must be persisted to disk, not just returned"
);
}
#[test]
fn ensure_live_owner_token_fails_loudly_when_the_refresh_token_is_also_dead() {
let dir = tempfile::tempdir().expect("tempdir");
let (base_url, done_rx) = stub_http::spawn(vec![
stub_http::canned(401, json!({ "error": "Invalid or expired token" })),
stub_http::canned(401, json!({ "error": "Invalid refresh token" })),
]);
let session_path =
write_pair_browser_test_session(dir.path(), "stale-token", "dead-refresh-token", &base_url);
let inst = pair_browser_test_instance("alice", session_path, base_url);
let error = ensure_live_owner_token(&inst, "stale-token")
.expect_err("a dead refresh token must not resolve to a usable token");
stub_http::await_done(&done_rx).expect("stub should have served both scripted responses");
let msg = format!("{error:#}");
assert!(msg.contains("alice"), "must name the instance: {msg}");
assert!(
msg.contains("attempted") && msg.contains("observed"),
"AC-0: must name what was attempted and what was observed: {msg}"
);
assert!(
msg.contains("harness up --clean"),
"must name the exact remedy command: {msg}"
);
}
#[test]
fn preflight_rejects_manifestless_app_dirs() {
let root = std::env::temp_dir().join(format!("harness-preflight-{}", std::process::id()));
let modules = root.join("modules");
std::fs::create_dir_all(modules.join("device-registry")).unwrap();
std::fs::write(
modules.join("device-registry/libnode_app_device_registry.dylib"),
b"",
)
.unwrap();
let error = preflight_apps(&root).unwrap_err().to_string();
assert!(error.contains("device-registry"), "got: {error}");
assert!(error.contains("core-storage"), "got: {error}");
assert!(error.contains("ldk-node"), "got: {error}");
assert!(error.contains("make bootstrap-apps"), "got: {error}");
assert!(error.contains("make build-ldk-node"), "got: {error}");
for app in CRITICAL_APPS {
std::fs::create_dir_all(modules.join(app)).unwrap();
std::fs::write(modules.join(app).join("manifest.json"), b"{}").unwrap();
}
let server_dir = root.join("system/server");
std::fs::create_dir_all(&server_dir).unwrap();
std::fs::write(server_dir.join("alice.env"), b"JWT_SECRET=test\n").unwrap();
std::fs::write(server_dir.join("bob.env"), b"JWT_SECRET=test\n").unwrap();
assert!(preflight_apps(&root).is_ok());
std::fs::remove_dir_all(&root).ok();
}
}