diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 67de1c9..5acbc09 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1218,7 +1218,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1468,7 +1468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2940,7 +2940,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2978,6 +2978,7 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-shell", + "tempfile", "tokenizers", "tokio", "tokio-stream", @@ -3501,7 +3502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -4456,7 +4457,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4957,7 +4958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5712,6 +5713,19 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.0" @@ -6157,7 +6171,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6936,7 +6950,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9a73b33..c6cead7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -143,6 +143,12 @@ tar = "0.4" [target.'cfg(target_os = "linux")'.dependencies] webkit2gtk = "2" +[dev-dependencies] +# Filesystem fixtures for migration tests. `tempdir()` + scoped +# cleanup so a failed test doesn't leak directories under the +# user's actual home. +tempfile = "3" + [profile.release] panic = "abort" codegen-units = 1 diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e76c86b..321d46e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -838,17 +838,36 @@ fn main() { #[cfg(target_os = "linux")] quiet_alsa_diagnostics(); - // Point myownmesh-core at our existing data directory before any - // mesh module is touched. The substrate defaults to `~/.myownmesh/`, - // but MyOwnLLM has shipped `~/.myownllm/.secrets/identity.json` and - // `~/.myownllm/mesh/rosters/*.json` for many releases — moving the - // anchor would orphan every user's Device ID and peer approvals. - // `MYOWNMESH_HOME` overrides the default at the source. Set - // unconditionally; explicit override wins if the user set one - // themselves (mostly relevant for cross-app test harnesses). + // Point myownmesh-core at a dedicated subdirectory under the LLM's + // tree (`~/.myownllm/.myownmesh/`) before any mesh module is + // touched. PRs #203/#205 set `MYOWNMESH_HOME=~/.myownllm` so the + // daemon shared identity + rosters with the LLM under one + // directory, but the daemon also writes its own + // `{MYOWNMESH_HOME}/config.json` — colliding with the LLM's + // `~/.myownllm/config.json` (different schemas). Any + // `NetworkAdd` IPC call triggered the daemon's + // `persist_network_add` → `MeshConfig::load() → push → save`, + // which silently dropped every LLM-only key from the loaded + // config and wrote the daemon shape back over the file. From the + // user's perspective: "I saved a network, restarted, and all my + // settings were gone." + // + // The subdirectory keeps the daemon's config.json + updates/ + // isolated. Identity, rosters, and governance states get moved + // into the subdir on first launch so existing users keep their + // pubkey + peer approvals — losing identity continuity would + // orphan every user's Device ID. Migration is idempotent and + // best-effort; the function logs failures to stderr and the + // daemon falls back to default-empty state in the worst case. if std::env::var_os("MYOWNMESH_HOME").is_none() { - if let Ok(dir) = myownllm_dir() { - std::env::set_var("MYOWNMESH_HOME", dir); + if let Ok(llm_dir) = myownllm_dir() { + let daemon_home = llm_dir.join(".myownmesh"); + if let Err(e) = + mesh::migration::migrate_daemon_state_into_subdir(&llm_dir, &daemon_home) + { + eprintln!("mesh-migration: failed: {e:#}"); + } + std::env::set_var("MYOWNMESH_HOME", &daemon_home); } } // Pre-multi-network rosters lived at `~/.myownllm/mesh/roster.json` diff --git a/src-tauri/src/mesh/daemon.rs b/src-tauri/src/mesh/daemon.rs index f29064c..033c893 100644 --- a/src-tauri/src/mesh/daemon.rs +++ b/src-tauri/src/mesh/daemon.rs @@ -9,12 +9,14 @@ //! 1. **Shared daemon**: `~/.myownmesh/daemon.sock`. If MyOwnMesh GUI //! is running, its daemon already binds this socket. Connecting //! here shares identity, roster, and networks across both apps. -//! 2. **LLM-owned daemon**: `~/.myownllm/daemon.sock`. We spawn -//! `myownmesh serve` with `MYOWNMESH_HOME=~/.myownllm` so the -//! daemon reads/writes the LLM's existing on-disk layout -//! (`identity.json`, `mesh/rosters/...`) instead of the -//! MyOwnMesh default. This keeps existing users on their current -//! pubkey when no GUI is present. +//! 2. **LLM-owned daemon**: `~/.myownllm/.myownmesh/daemon.sock`. +//! We spawn `myownmesh serve` with +//! `MYOWNMESH_HOME=~/.myownllm/.myownmesh/` so the daemon's +//! `config.json` + `updates/` stay isolated from the LLM's +//! `~/.myownllm/config.json` + `~/.myownllm/updates/`. Identity, +//! rosters, and signed governance states get pre-migrated into +//! the subdir by `mesh::migration::migrate_daemon_state_into_subdir` +//! so existing users keep their pubkey + peer approvals. //! //! The choice is sticky for the app lifetime — we don't dynamically //! switch sockets if the GUI starts mid-session. A future "merge @@ -221,7 +223,8 @@ pub enum DaemonMode { /// lifetime. Shared, /// We spawned the daemon ourselves with - /// `MYOWNMESH_HOME=~/.myownllm`. We own the child process. + /// `MYOWNMESH_HOME=~/.myownllm/.myownmesh/`. We own the child + /// process. OwnLlm, } @@ -246,13 +249,22 @@ impl fmt::Display for SocketAddr { /// On Windows the namespaced pipe segment is shared between modes — /// we still spawn our own daemon if probing the existing one fails, /// but the wire name is the same. +/// +/// Per-mode Unix paths mirror the daemon's +/// `MYOWNMESH_HOME/daemon.sock` layout: +/// - `Shared`: `~/.myownmesh/daemon.sock` — the MyOwnMesh GUI's +/// default location. +/// - `OwnLlm`: `~/.myownllm/.myownmesh/daemon.sock` — the LLM +/// spawns its own daemon with `MYOWNMESH_HOME=~/.myownllm/.myownmesh/` +/// so the daemon's `config.json` + `updates/` don't collide with +/// the LLM's. See `mesh/migration.rs` for the why. fn socket_for_mode(mode: DaemonMode) -> Result { #[cfg(unix)] { let home = dirs::home_dir().context("no home dir")?; let dir = match mode { DaemonMode::Shared => home.join(".myownmesh"), - DaemonMode::OwnLlm => home.join(".myownllm"), + DaemonMode::OwnLlm => home.join(".myownllm").join(".myownmesh"), }; Ok(SocketAddr::Path(dir.join("daemon.sock"))) } @@ -757,7 +769,8 @@ pub async fn ensure_daemon_running() -> Result<(ControlClient, Option Result<(ControlClient, Option Result<(ControlClient, Option = None; for bin in &candidates { diff --git a/src-tauri/src/mesh/migration.rs b/src-tauri/src/mesh/migration.rs new file mode 100644 index 0000000..c475b1b --- /dev/null +++ b/src-tauri/src/mesh/migration.rs @@ -0,0 +1,294 @@ +//! One-shot migration: move daemon-owned state into a dedicated subdir. +//! +//! ## Why +//! +//! Up through PR #205 the LLM spawned `myownmesh serve` with +//! `MYOWNMESH_HOME=~/.myownllm` so the daemon shared identity + +//! rosters with the LLM under one directory. That worked for +//! identity / rosters / states, but the daemon also persists its own +//! `config.json` to `{MYOWNMESH_HOME}/config.json` — the *same* file +//! the LLM uses for *its* config. The two have different schemas: +//! +//! - LLM: `{providers, active_family, cloud_mesh.networks, ...}` +//! - daemon (myownmesh-core `MeshConfig`): `{version, identity_path, +//! auto_update, auto_cleanup, daemon, networks}` +//! +//! The daemon's `MeshConfig::load()` doesn't use +//! `#[serde(deny_unknown_fields)]`, so loading the LLM's config +//! silently dropped every LLM-only key. Then any `NetworkAdd` / +//! `NetworkRemove` IPC call triggered `persist_network_add`, which +//! re-serialised `MeshConfig` over the file — wiping the LLM's +//! `providers`, `active_family`, `cloud_mesh`, prompts, permissions, +//! everything. On next launch the LLM saw an unrecognisable file and +//! reset to factory defaults. From the user's perspective: +//! "I clicked Save & Activate on a network, restarted, and every +//! setting was gone." +//! +//! ## Fix +//! +//! Isolate the daemon under a subdirectory: +//! `~/.myownllm/.myownmesh/`. The daemon's `config.json` and +//! `updates/` live there, leaving the LLM's parent `~/.myownllm/` +//! tree untouched. Identity / rosters / states get moved into the +//! subdir on first launch so existing users keep their pubkey + peer +//! approvals — losing identity continuity across this migration +//! would orphan every user's Device ID and force every paired peer +//! through a fresh approval round. +//! +//! ## Idempotence +//! +//! Every step checks for the destination first. Re-running the +//! migration is a no-op on the second launch, on every subsequent +//! launch, and on a fresh install where there's nothing to move. +//! Failures during move are non-fatal — we log to stderr and let the +//! daemon fall back to its own default-empty state. + +use anyhow::Result; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Move daemon-owned state files from the LLM's parent directory +/// into `daemon_home` so the daemon's `config.json` doesn't collide +/// with the LLM's. Called from `main.rs` before `MYOWNMESH_HOME` is +/// set so we can find the source files at their pre-isolation paths. +/// +/// Three groups move: +/// 1. `.secrets/identity.json` — long-lived ed25519 keypair. Moving +/// this is what keeps existing users' Device ID intact across the +/// migration. +/// 2. `mesh/rosters/*.json` — per-network approved peers. Without +/// these, every paired peer would re-prompt for approval on next +/// handshake. +/// 3. `mesh/states/*.json` — signed governance-state files for +/// closed networks. Required for closed-network identity +/// continuity (the founder's signed log can't be regenerated). +/// +/// Other files in `llm_dir/mesh/` (or anywhere else) are left in +/// place — they're LLM-owned. +pub fn migrate_daemon_state_into_subdir(llm_dir: &Path, daemon_home: &Path) -> Result<()> { + // Skip when the daemon home already has the canonical files — + // we ran on a previous launch. + let identity_dst = daemon_home.join(".secrets").join("identity.json"); + if identity_dst.exists() { + return Ok(()); + } + + // 1. Identity anchor. The most consequential — losing this + // rotates the user's pubkey and forces every paired peer + // through re-approval. + let identity_src = llm_dir.join(".secrets").join("identity.json"); + move_file(&identity_src, &identity_dst); + + // 2. Roster files (per-network approved peers). + move_dir_contents( + &llm_dir.join("mesh").join("rosters"), + &daemon_home.join("mesh").join("rosters"), + ); + + // 3. Governance state files (closed-network signed logs). + move_dir_contents( + &llm_dir.join("mesh").join("states"), + &daemon_home.join("mesh").join("states"), + ); + + Ok(()) +} + +/// Move a single file, creating parent directories. Best-effort: +/// missing source = nothing to do; destination collision = leave +/// both in place and log (the migration only runs once, so a +/// collision means an earlier run was interrupted and the user has +/// a duplicate; we can't safely choose for them). +fn move_file(src: &Path, dst: &Path) { + if !src.exists() { + return; + } + if dst.exists() { + eprintln!( + "mesh-migration: destination already exists, leaving both in place: {}", + dst.display() + ); + return; + } + if let Some(parent) = dst.parent() { + if let Err(e) = fs::create_dir_all(parent) { + eprintln!( + "mesh-migration: create_dir_all({}) failed: {e}", + parent.display() + ); + return; + } + } + // Try rename first (cheap; preserves permissions). Fall back to + // copy + remove for cross-filesystem moves (rare under a single + // home dir but cheap to handle correctly). + if fs::rename(src, dst).is_err() { + if let Err(e) = fs::copy(src, dst) { + eprintln!( + "mesh-migration: copy({} -> {}) failed: {e}", + src.display(), + dst.display() + ); + return; + } + let _ = fs::remove_file(src); + } +} + +/// Move every regular file in `src_dir` into `dst_dir`. Used for +/// roster and state directories which carry one file per network. +/// Doesn't recurse — both directories are flat by design. +fn move_dir_contents(src_dir: &Path, dst_dir: &Path) { + if !src_dir.exists() { + return; + } + let entries = match fs::read_dir(src_dir) { + Ok(e) => e, + Err(e) => { + eprintln!( + "mesh-migration: read_dir({}) failed: {e}", + src_dir.display() + ); + return; + } + }; + let to_move: Vec = entries + .filter_map(|r| r.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file()) + .collect(); + if to_move.is_empty() { + // Empty source dir — nothing to migrate. + return; + } + for src in &to_move { + let Some(name) = src.file_name() else { + continue; + }; + let dst = dst_dir.join(name); + move_file(src, &dst); + } + // Best-effort cleanup of the now-empty source directory. We + // don't fail the migration if it's still got non-file entries + // (shouldn't happen, but symlinks / hidden state we don't own). + if fs::read_dir(src_dir) + .map(|d| d.count() == 0) + .unwrap_or(false) + { + let _ = fs::remove_dir(src_dir); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn tempdir() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") + } + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut f = fs::File::create(path).unwrap(); + f.write_all(content.as_bytes()).unwrap(); + } + + #[test] + fn moves_identity_and_rosters_into_subdir() { + let tmp = tempdir(); + let llm = tmp.path(); + let daemon = llm.join(".myownmesh"); + + write(&llm.join(".secrets").join("identity.json"), "id-data"); + write(&llm.join("mesh").join("rosters").join("home.json"), "home"); + write( + &llm.join("mesh").join("rosters").join("office.json"), + "office", + ); + + migrate_daemon_state_into_subdir(llm, &daemon).unwrap(); + + // Destinations populated. + assert_eq!( + fs::read_to_string(daemon.join(".secrets").join("identity.json")).unwrap(), + "id-data" + ); + assert_eq!( + fs::read_to_string(daemon.join("mesh").join("rosters").join("home.json")).unwrap(), + "home" + ); + assert_eq!( + fs::read_to_string(daemon.join("mesh").join("rosters").join("office.json")).unwrap(), + "office" + ); + // Sources gone. + assert!(!llm.join(".secrets").join("identity.json").exists()); + assert!(!llm.join("mesh").join("rosters").join("home.json").exists()); + } + + #[test] + fn idempotent_after_first_run() { + let tmp = tempdir(); + let llm = tmp.path(); + let daemon = llm.join(".myownmesh"); + + write(&llm.join(".secrets").join("identity.json"), "v1"); + migrate_daemon_state_into_subdir(llm, &daemon).unwrap(); + + // Second run: no-op. + write(&llm.join(".secrets").join("identity.json"), "v2-stray"); + migrate_daemon_state_into_subdir(llm, &daemon).unwrap(); + // First-run value preserved; second-run stray ignored. + assert_eq!( + fs::read_to_string(daemon.join(".secrets").join("identity.json")).unwrap(), + "v1" + ); + } + + #[test] + fn fresh_install_with_no_sources_is_no_op() { + let tmp = tempdir(); + let llm = tmp.path(); + let daemon = llm.join(".myownmesh"); + + migrate_daemon_state_into_subdir(llm, &daemon).unwrap(); + + assert!(!daemon.join(".secrets").join("identity.json").exists()); + } + + #[test] + fn destination_collision_leaves_both_in_place() { + let tmp = tempdir(); + let llm = tmp.path(); + let daemon = llm.join(".myownmesh"); + + write(&llm.join(".secrets").join("identity.json"), "src"); + // Simulate a partial previous run: identity destination + // already populated, but `identity.json` itself doesn't + // exist at the well-known dst path — so we pre-create it + // at a different file in the destination tree. + write( + &daemon.join("mesh").join("rosters").join("home.json"), + "dst", + ); + write(&llm.join("mesh").join("rosters").join("home.json"), "src"); + + migrate_daemon_state_into_subdir(llm, &daemon).unwrap(); + + // identity moves (its dst didn't exist). + assert_eq!( + fs::read_to_string(daemon.join(".secrets").join("identity.json")).unwrap(), + "src" + ); + // Roster home.json: destination already exists; both + // remain. + assert_eq!( + fs::read_to_string(daemon.join("mesh").join("rosters").join("home.json")).unwrap(), + "dst" + ); + assert!(llm.join("mesh").join("rosters").join("home.json").exists()); + } +} diff --git a/src-tauri/src/mesh/mod.rs b/src-tauri/src/mesh/mod.rs index 7b3956c..de784b2 100644 --- a/src-tauri/src/mesh/mod.rs +++ b/src-tauri/src/mesh/mod.rs @@ -10,10 +10,13 @@ //! [`myownmesh-core`](https://github.com/mrjeeves/MyOwnMesh) crate. //! The submodules below are thin re-exports of the substrate's API so //! `commands.rs` and the Tauri handler list don't churn. The -//! substrate reads/writes `~/.myownllm/` via the `MYOWNMESH_HOME` -//! override set in `main.rs`, so existing user data -//! (`identity.json`, `mesh/rosters/*.json`) survives the migration -//! untouched. +//! substrate reads/writes `~/.myownllm/.myownmesh/` via the +//! `MYOWNMESH_HOME` override set in `main.rs`. Existing user data +//! (`identity.json`, `mesh/rosters/*.json`) lived at the LLM-parent +//! path (`~/.myownllm/.secrets/`, `~/.myownllm/mesh/rosters/`) up +//! through PR #205; the `migration` submodule's one-shot move +//! relocates them into the subdir on first launch after this PR so +//! existing users keep their pubkey + peer approvals. //! //! Submodule layout: //! - `identity`: re-exports of `myownmesh_core::identity` — @@ -25,6 +28,11 @@ //! local one-shot `migrate_legacy_if_present` that runs at //! startup to move pre-multi-network roster files into their //! per-network homes. +//! - `migration`: one-shot move of daemon-owned state into a +//! dedicated subdir (`~/.myownllm/.myownmesh/`) so the +//! daemon's `config.json` doesn't collide with the LLM's. Runs +//! on the first launch after this isolation landed; idempotent +//! thereafter. //! - `commands`: Tauri commands exposed to the Svelte UI. pub mod commands; @@ -32,5 +40,6 @@ pub mod daemon; pub mod daemon_commands; pub mod governance; pub mod identity; +pub mod migration; pub mod roster; pub mod signing; diff --git a/src/config.ts b/src/config.ts index aab619e..b7bda88 100644 --- a/src/config.ts +++ b/src/config.ts @@ -181,6 +181,17 @@ export async function loadConfig(): Promise { } function mergeDefaults(raw: Record): Config { + // One-shot recovery for users hit by the daemon-collision bug + // (pre-PR #208): the bundled `myownmesh` daemon used to write its + // own `MeshConfig`-shape `config.json` over the LLM's + // `~/.myownllm/config.json`, wiping every LLM key. This rebuilds + // what we can — the daemon kept the user's networks at a + // top-level `networks` field, so we lift them into + // `cloud_mesh.networks` (with sensible defaults for the + // LLM-only fields the daemon doesn't carry), and strip the + // daemon-shape leftover keys so subsequent saves are clean. + raw = salvageDaemonShapeLeakage(raw); + const merged: Config = { ...DEFAULT_CONFIG, ...(raw as Partial), @@ -250,6 +261,162 @@ function mergeDefaults(raw: Record): Config { return merged; } +/** One-shot recovery for the daemon-collision bug fixed in PR #208. + * + * Up through that PR, the bundled `myownmesh serve` daemon was + * spawned with `MYOWNMESH_HOME=~/.myownllm`, which put its own + * `MeshConfig`-shape `config.json` at the same path as the LLM's + * `~/.myownllm/config.json`. Any `NetworkAdd` IPC call triggered + * the daemon's `persist_network_add` → `MeshConfig::load() → push + * → save`, which silently dropped every LLM-only key from the + * loaded config and wrote the daemon shape back over the file. + * + * Users who hit this see, on next launch, a config.json with the + * daemon's top-level `{version, identity_path, auto_update, + * auto_cleanup, daemon, networks}` shape and none of the LLM's + * fields. The new build's Rust-side isolation prevents recurrence, + * but the file on disk needs cleanup. + * + * This function: + * 1. Detects the daemon shape (top-level `networks` array with + * `id` + `network_id` fields, AND `cloud_mesh.networks` empty + * or absent). + * 2. Converts each daemon `NetworkConfig` into the LLM's flat + * shape and seeds `cloud_mesh.networks` with the result. + * LLM-only fields the daemon doesn't carry (`accepting`, + * `agent_permissions`, `prompts`, `auto_gossip`) default to + * their fresh values via `mergeNetwork`. + * 3. Strips the daemon-shape leftover top-level keys so the + * saved-back file is clean LLM shape going forward. + * + * No-op when nothing matches the detection signature — fresh + * installs and uncorrupted configs are untouched. */ +function salvageDaemonShapeLeakage( + raw: Record, +): Record { + const daemonNetworks = raw["networks"]; + if (!Array.isArray(daemonNetworks) || daemonNetworks.length === 0) { + return raw; + } + // Detection: each entry has `id` and `network_id`. If even one + // entry looks malformed, don't touch the file — the user might + // have hand-edited something we don't want to clobber. + const allDaemonShape = daemonNetworks.every( + (n): n is Record => + !!n && + typeof n === "object" && + typeof (n as Record).id === "string" && + typeof (n as Record).network_id === "string", + ); + if (!allDaemonShape) return raw; + // Only salvage when the LLM-side cloud_mesh.networks isn't + // already populated — otherwise we'd risk double-adding. + const existingCloudMesh = raw["cloud_mesh"] as + | { networks?: unknown } + | undefined; + const existingLlmNetworks = Array.isArray(existingCloudMesh?.networks) + ? (existingCloudMesh!.networks as unknown[]) + : []; + if (existingLlmNetworks.length > 0) { + // We have both shapes. Trust the LLM shape; just strip the + // daemon-shape leftovers so saves stay clean. + return stripDaemonLeftovers(raw); + } + // Convert daemon NetworkConfig → LLM NetworkConfig. Field + // mapping: + // daemon.signaling.servers → llm.signaling_servers + // daemon.stun_servers[].urls flat → llm.stun_servers + // daemon.turn_servers[].urls[0]/auth → llm.turn_servers[] + // everything else (label, kind, topology, auto_approve) + // passes straight through. + const recovered: Array> = daemonNetworks.map((n) => { + const d = n as Record; + const signaling = d["signaling"] as + | { servers?: unknown } + | undefined; + const signaling_servers = Array.isArray(signaling?.servers) + ? (signaling!.servers as unknown[]).filter( + (s): s is string => typeof s === "string", + ) + : []; + const stunRaw = Array.isArray(d["stun_servers"]) + ? (d["stun_servers"] as unknown[]) + : []; + const stun_servers: string[] = []; + for (const entry of stunRaw) { + if (!entry || typeof entry !== "object") continue; + const urls = (entry as { urls?: unknown }).urls; + if (Array.isArray(urls)) { + for (const u of urls) { + if (typeof u === "string") stun_servers.push(u); + } + } + } + const turnRaw = Array.isArray(d["turn_servers"]) + ? (d["turn_servers"] as unknown[]) + : []; + const turn_servers: NetworkConfig["turn_servers"] = []; + for (const entry of turnRaw) { + if (!entry || typeof entry !== "object") continue; + const e = entry as Record; + const urls = Array.isArray(e["urls"]) ? (e["urls"] as unknown[]) : []; + const firstUrl = urls.find((u): u is string => typeof u === "string"); + if (!firstUrl) continue; + turn_servers.push({ + url: firstUrl, + username: + typeof e["username"] === "string" ? (e["username"] as string) : undefined, + credential: + typeof e["credential"] === "string" + ? (e["credential"] as string) + : undefined, + }); + } + return { + id: d["id"] as string, + network_id: d["network_id"] as string, + label: typeof d["label"] === "string" ? (d["label"] as string) : undefined, + kind: + d["kind"] === "open" || d["kind"] === "closed" + ? (d["kind"] as NetworkConfig["kind"]) + : undefined, + topology: d["topology"] as NetworkConfig["topology"] | undefined, + auto_approve: + typeof d["auto_approve"] === "boolean" + ? (d["auto_approve"] as boolean) + : undefined, + signaling_servers, + stun_servers, + turn_servers, + }; + }); + const stripped = stripDaemonLeftovers(raw); + stripped["cloud_mesh"] = { + ...(stripped["cloud_mesh"] as Record | undefined), + networks: recovered, + }; + return stripped; +} + +/** Remove top-level keys the daemon previously wrote into our + * config.json so the saved-back file stays clean LLM shape. + * + * `auto_update` and `auto_cleanup` are shared keys (both shapes + * define them with compatible fields); the LLM's `mergeDefaults` + * merges them with LLM defaults so the user's values — whichever + * shape they were last in — are preserved. Only the daemon-only + * keys come out. */ +function stripDaemonLeftovers( + raw: Record, +): Record { + const out = { ...raw }; + delete out["version"]; + delete out["identity_path"]; + delete out["daemon"]; + delete out["networks"]; + return out; +} + /** Generate a stable internal id for a saved network. Independent * of `network_id` so renaming the user-facing handle is allowed * without breaking the `active_network_id` pointer. Crockford-ish