1	//! The daemon process entrypoint + auto-start (B6).
2	//!
3	//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
4	//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
5	//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
6	//! of the process.
7	//!
8	//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
9	//! invocation calls it; if no daemon answers the seed-control ping it spawns
10	//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
11	//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
12	//! against it is a true liveness test (`listen` is the interim model's anchor;
13	//! here the seed channel plays that role for cold-start detection).
14	// [impl->REQ-DAEMON-3]
15
16	use std::io;
17	#[cfg(unix)]
18	use std::process::{Command, Stdio};
19	use std::sync::Arc;
20	use std::thread;
21	use std::time::Duration;
22
23	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
24
25	use crate::broker::{default_journal_path, Broker};
26	use crate::config::{DaemonConfig, RelayChoice};
27	use crate::digesthub::{self, DigestHub};
28	use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
29	use crate::nethost::{NetConfig, NetHost};
30	use crate::seedmap::{self, SeedRegistry};
31
32	/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
33	const START_TIMEOUT: Duration = Duration::from_secs(5);
34	/// Poll cadence while waiting for the spawned daemon to come up.
35	const START_POLL: Duration = Duration::from_millis(25);
36
37	/// The per-machine broker/brain daemon process.
38	pub struct Daemon;
39
40	impl Daemon {
41	    /// Run the daemon: bind the broker (served on a background thread) + the
42	    /// seed-control channel (the foreground loop + liveness anchor) and serve
43	    /// until the process is killed. The body of `spt daemon`.
44	    pub fn run() -> io::Result<()> {
45	        // Entry elevation guard (KNOWN-HAZARDS 5.7): a DIRECTLY elevated
46	        // `spt daemon` (sudo / elevated shell) must not serve with the
47	        // elevated token — its pipes would deny unelevated clients (Windows)
48	        // or root the user's state universe (Linux). Unix drops privileges
49	        // in-process before any state is touched; Windows cannot drop a
50	        // token in-place, so it respawns de-elevated via the UAC linked
51	        // token and exits (the child re-enters here unelevated). With no
52	        // unelevated identity to drop to, serving continues as-is, loudly —
53	        // a consistent universe, never a torn one.
54	        // [impl->REQ-HAZARD-ELEVATED-DAEMON-SPAWN]
55	        // Drop target: the ELECTED default user when one exists, else the
56	        // sudo invoker — electing it on first use (M8 decision 8,
57	        // REQ-INSTALL-6: the first `sudo spt` decides; thereafter every
58	        // elevated launch lands in the same account's universe).
59	        // [impl->REQ-INSTALL-6]
60	        #[cfg(unix)]
61	        if let Some(invoker) = crate::deelevate::daemon_target_user() {
62	            match invoker.drop_in_process() {
63	                Ok(()) => eprintln!("DEELEVATED: daemon dropped to uid {}", invoker.uid),
64	                // A drop target existed but the drop failed: serving as
65	                // root would root the user's state universe — a known-torn
66	                // state. ABORT (user-ratified 2026-06-06: refuse, don't limp).
67	                Err(e) => {
68	                    return Err(io::Error::other(format!(
69	                        "DEELEVATE_FAIL: {e} — refusing to serve as root over a user universe; \
70	                         run spt unelevated"
71	                    )));
72	                }
73	            }
74	        }
75	        #[cfg(windows)]
76	        if crate::deelevate::is_elevated() {
77	            let exe = std::env::current_exe()?;
78	            match crate::deelevate::spawn_deelevated(
79	                &exe.to_string_lossy(),
80	                &["daemon".to_string(), "run".to_string()],
81	            ) {
82	                Ok(Some(pid)) => {
83	                    eprintln!(
84	                        "DEELEVATED_RESPAWN: unelevated daemon pid {pid}; elevated copy exits"
85	                    );
86	                    return Ok(());
87	                }
88	                // Uniformly-elevated universe (no unelevated identity):
89	                // serving elevated is consistent there. Loud anyway.
90	                Ok(None) => eprintln!(
91	                    "ELEVATED_DAEMON: no unelevated identity to drop to — serving with the \
92	                     elevated token"
93	                ),
94	                // Target existed, respawn failed: an elevated daemon is a
95	                // known-torn state. ABORT (user-ratified 2026-06-06).
96	                Err(e) => {
97	                    return Err(io::Error::other(format!(
98	                        "DEELEVATE_FAIL: {e} — refusing to serve with the elevated token; run \
99	                         spt from an unelevated shell"
100	                    )));
101	                }
102	            }
103	        }
104
105	        let reg = Arc::new(SeedRegistry::new());
106
107	        // Boot housekeeping (KNOWN-HAZARDS 1.3): sweep stale `index.lock`
108	        // wedges out of the git-touching runtime the daemon hosts — the
109	        // context store's seed repo + its agent/project worktrees (a crashed
110	        // git leaves a 0-byte lock that blocks every later commit forever).
111	        // Path composed via the layout authority without opening the full
112	        // ContextStore on the boot path; a lock-less (or store-less) home
113	        // no-ops.
114	        // [impl->REQ-HAZARD-STALE-INDEX-LOCK]
115	        for lock in spt_store::branchstore::sweep_stale_index_locks(
116	            &spt_store::contextstore::seed_git_dir(&spt_store::perch::tracked_dir()),
117	            spt_store::branchstore::STALE_LOCK_AGE,
118	        ) {
119	            eprintln!("STALE_LOCK_SWEPT:{}", lock.display());
120	        }
121
122	        // Serve-state boot reset (M8 decision 6, REQ-SUBNET-5): the live
123	        // attachment state starts from the config's startup defaults — an
124	        // unsaved attach/detach flip deliberately does not survive a daemon
125	        // restart (`--save` is the persistence path).
126	        // [impl->REQ-SUBNET-5]
127	        {
128	            let att = spt_store::attachment::AttachmentStore::reset_to(
129	                &DaemonConfig::load().detached_subnets,
130	            );
131	            if let Err(e) = att.save() {
132	                eprintln!("ATTACHMENT_RESET_FAIL: {e} — serving with all subnets attached");
133	            }
134	            for name in &att.detached {
135	                eprintln!("SUBNET_DETACHED:{name} (startup default — `spt subnet attach {name}`)");
136	            }
137	        }
138
139	        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
140	        // + live QUIC connections live in the broker so they survive a brain
141	        // restart. Identity is the durable node.key (stable name across daemon
142	        // restarts); relay stance comes from daemon.json; mDNS rides the
143	        // endpoint (closing the §B mDNS/relay-session row by construction). A
144	        // net failure degrades to a net-less broker — it must not cost PTY
145	        // hosting (mirrors the broker-bind soft-fail below).
146	        // [impl->REQ-DAEMON-2]
147	        // Identity (the durable node.key) decides node_hex independently of the
148	        // net bring-up: a NET_BIND_FAIL (e.g. the autostart beat the network up
149	        // at boot — REQ-DAEMON-9) is transient and retried, but a broken
150	        // identity is not, so we only arm the retry when identity is sound.
151	        let node_hex: Option<String> = spt_store::nodeid::load_or_create()
152	            .ok()
153	            .map(|i| i.public_key().to_hex());
154	        let net = if node_hex.is_some() {
155	            try_start_net()
156	        } else {
157	            eprintln!("NODE_KEY_FAIL: identity unavailable — broker runs net-less, no retry");
158	            None
159	        };
160
161	        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
162	        // background thread; a bind failure there must not stop the seed channel
163	        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
164	        let net_up = net.is_some();
165	        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
166	            Ok(broker) => {
167	                let serve_broker = Arc::clone(&broker);
168	                thread::spawn(move || {
169	                    let _ = serve_broker.serve();
170	                });
171	                // Inbound net dispatch + outbound peer pump (D9-1): only
172	                // meaningful with a net host — a net-less broker accepts nothing
173	                // and dials nobody.
174	                let hex = node_hex.clone().unwrap_or_default();
175	                if net_up {
176	                    spawn_net_consumers(&hex);
177	                } else if node_hex.is_some() {
178	                    // Boot-race self-heal (REQ-DAEMON-9): net failed to bind but
179	                    // identity is sound — almost always the autostart racing the
180	                    // network up at boot. Retry net in the background; attach it
181	                    // to the broker + spawn the consumers when it succeeds, so
182	                    // the node becomes reachable WITHOUT a manual restart.
183	                    // [impl->REQ-DAEMON-9]
184	                    let broker = Arc::clone(&broker);
185	                    thread::spawn(move || net_retry_attach(broker, hex));
186	                }
187	            }
188	            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
189	        }
190
191	        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
192	        // the digest-control channel serves `spt digest <id>` snapshot pulls +
193	        // delta-stream subscribes against it. Served on a background thread like
194	        // the broker. The per-session feed that *populates* the hub is driven by
195	        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
196	        // sessions are spawned through the broker; a bare daemon serves the
197	        // channel and answers "no live digest" until a session is hosted.
198	        let digest_hub = Arc::new(DigestHub::new());
199	        {
200	            let hub = Arc::clone(&digest_hub);
201	            thread::spawn(move || {
202	                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
203	            });
204	        }
205
206	        // Shell wake-watchers (M5-D4b): the daemon hosts the offline half of
207	        // the shell online/offline exclusivity — a reconcile loop that runs
208	        // each offline instance's manifest `wake_command` as a supervised
209	        // watcher child (exit-opcode → wake resolution) and stops it when the
210	        // binary side wins. Boot sweep doubles as orphan cleanup after a
211	        // daemon crash. The daemon runs until killed, so the stop flag here
212	        // is held for symmetry with the dispatcher loops, never raised.
213	        // [impl->REQ-SHELL-2]
214	        {
215	            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
216	            let _ = crate::shellwake::spawn_wake_host(stop);
217	        }
218
219	        // Brain process supervisor (ADR-0018 Q2/Q3 — broker/brain process
220	        // restoration, D1 skeleton): the broker is the always-up per-machine
221	        // anchor; it spawns `spt daemon brain` as a supervised child and
222	        // respawns it on exit (an update is a planned brain self-exit on this
223	        // same path). D1 is a true minimal skeleton — the brain child connects,
224	        // signals ready, and idles; every existing loop above stays in THIS
225	        // (broker) process until D2 migrates it to a versioned verb. The stop
226	        // flag is held for symmetry with the other resident loops, never raised
227	        // (the daemon runs until killed). The brain child inherits this
228	        // process's already-unelevated token as a plain managed child.
229	        // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
230	        {
231	            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
232	            let _ = crate::brainproc::spawn_brain_supervisor(stop);
233	        }
234
235	        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
236	        // is the socket ping, never this file, so a stale value is harmless.
237	        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());
238
239	        // The seed-control channel is the foreground loop. Binding it is what
240	        // arbitrates the single-daemon-per-home invariant: a second daemon racing
241	        // to start fails this bind and exits, leaving the winner serving.
242	        seedmap::serve_seed_control(&seed_socket_name(), reg)
243	    }
244
245	    /// Run the brain child (`spt daemon brain`, hidden — ADR-0018 D1). The
246	    /// broker process spawns + supervises this; it connects to the broker over
247	    /// the existing socket IPC, signals ready, and idles. Not a user-facing
248	    /// entry — `spt daemon run` (the broker) launches it.
249	    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
250	    pub fn run_brain() -> io::Result<()> {
251	        crate::brainproc::run_brain()
252	    }
253	}
254
255	/// Try to bring up the broker's network host once: build the production
256	/// `NetConfig` (durable node.key + relay/discovery/membership) and start the
257	/// endpoint, wiring on-connect roster propagation (REQ-MESH-2). `None` on a
258	/// `NET_BIND_FAIL` (e.g. the network/DNS stack not ready yet) or a missing
259	/// identity — the caller binds net-less and (REQ-DAEMON-9) retries.
260	// [impl->REQ-DAEMON-2]
261	// [impl->REQ-DAEMON-9]
262	fn try_start_net() -> Option<NetHost> {
263	    let cfg = DaemonConfig::load();
264	    let relay = match cfg.relay {
265	        RelayChoice::N0 => RelayPolicy::N0Default,
266	        RelayChoice::Disabled => RelayPolicy::Disabled,
267	    };
268	    let identity = spt_store::nodeid::load_or_create().ok()?;
269	    let net_cfg = NetConfig {
270	        identity,
271	        relay,
272	        discovery: LocalDiscovery::Mdns,
273	        scope: BindScope::All, // production: real interfaces
274	        // Mesh-D2: enforce seed-proof on every dial + accept, reading the live
275	        // subnet store each connect.
276	        membership: Some(crate::seedproofx::production_membership()),
277	    };
278	    match NetHost::start(net_cfg) {
279	        Ok(host) => {
280	            host.set_roster_exchange(crate::seedproofx::production_roster_exchange());
281	            Some(host)
282	        }
283	        Err(e) => {
284	            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
285	            None
286	        }
287	    }
288	}
289
290	/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump
291	/// (D9-1) — against the now-net-attached broker. Called once at boot (net up
292	/// immediately) OR once from the self-heal retry when net attaches later
293	/// (REQ-DAEMON-9). Self-contained: mints its own registry + stop flag.
294	// [impl->REQ-DAEMON-9]
295	fn spawn_net_consumers(node_hex: &str) {
296	    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_hex));
297	    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
298	    let _ = crate::dispatch::spawn_dispatcher(
299	        broker_socket_name(),
300	        Arc::clone(&registry),
301	        Arc::clone(&stop),
302	    );
303	    let cfg = DaemonConfig::load();
304	    if cfg.peer_pump {
305	        let _ = crate::peerloop::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);
306	    }
307	}
308
309	/// Background net self-heal supervisor (REQ-DAEMON-9): a daemon that came up
310	/// net-less (NET_BIND_FAIL at boot — typically the autostart racing the network
311	/// up) keeps retrying `try_start_net` with capped backoff and, on success,
312	/// ATTACHES the host to the live broker (`OnceLock`, so exactly once) and spawns
313	/// the net consumers. The node becomes reachable without a manual restart. Runs
314	/// until net binds — the network always arrives eventually after boot.
315	// [impl->REQ-DAEMON-9]
316	fn net_retry_attach(broker: Arc<Broker>, node_hex: String) {
317	    let mut backoff = NET_RETRY_FIRST;
318	    loop {
319	        thread::sleep(backoff);
320	        if let Some(host) = try_start_net() {
321	            if broker.attach_net(host) {
322	                eprintln!("NET_ATTACHED: net endpoint bound on retry — peer pump starting");
323	                spawn_net_consumers(&node_hex);
324	            }
325	            return;
326	        }
327	        backoff = net_retry_backoff(backoff);
328	        eprintln!("NET_BIND_RETRY: net still unavailable, retrying in {}s", backoff.as_secs());
329	    }
330	}
331
332	const NET_RETRY_FIRST: Duration = Duration::from_secs(1);
333	const NET_RETRY_CAP: Duration = Duration::from_secs(30);
334
335	/// The net self-heal backoff ladder (REQ-DAEMON-9): double each retry, capped
336	/// at [`NET_RETRY_CAP`]. Pure for tests.
337	fn net_retry_backoff(cur: Duration) -> Duration {
338	    (cur * 2).min(NET_RETRY_CAP)
339	}
340
341	/// Whether a daemon is already serving the seed-control channel for this home.
342	pub fn is_running() -> bool {
343	    seedmap::ping(&seed_socket_name()).is_ok()
344	}
345
346	/// Gracefully stop the daemon serving this home (`spt daemon stop`, M8
347	/// decision 5 / REQ-CLI-2): the seed-control stop op — acked, then the
348	/// daemon's foreground loop returns and the process exits cleanly.
349	// [impl->REQ-CLI-2]
350	pub fn request_stop() -> io::Result<()> {
351	    seedmap::request_stop(&seed_socket_name())
352	}
353
354	/// Block (bounded) until a daemon answers the seed-control channel, or time
355	/// out. Shared by every start path (`ensure_running`, `start_daemon`):
356	/// race-tolerant — if two callers spawn at once, one wins the bind and this
357	/// passes either way.
358	fn wait_until_up() -> io::Result<()> {
359	    let mut waited = Duration::ZERO;
360	    while waited < START_TIMEOUT {
361	        if is_running() {
362	            return Ok(());
363	        }
364	        thread::sleep(START_POLL);
365	        waited += START_POLL;
366	    }
367	    Err(io::Error::new(
368	        io::ErrorKind::TimedOut,
369	        "daemon did not come up within the start window",
370	    ))
371	}
372
373	/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if
374	/// one already answers; otherwise bring it up and wait (bounded) for the bind.
375	///
376	/// Service-aware (REQ-DAEMON-8): when an OS service manager has a registered
377	/// spt-daemon, this starts THAT — never a competing manual `spawn_detached`
378	/// daemon that would fight the service for the broker socket (the kitsubito
379	/// 2026-06-08 restart-loop). No manager ⇒ the detached spawn path as before.
380	// [impl->REQ-DAEMON-8]
381	pub fn ensure_running() -> io::Result<()> {
382	    if is_running() {
383	        return Ok(());
384	    }
385	    let svc = crate::service::platform_service();
386	    match crate::service::plan_start(false, svc.detected()) {
387	        crate::service::StartPlan::ServiceStart => {
388	            // Prefer the manager — but if it can't be driven (no session bus on
389	            // a default-home box, manager not running), fall back to a manual
390	            // daemon rather than leave the caller daemon-less. The autostart
391	            // path must never hard-fail when a spawn would have worked.
392	            if let Err(e) = svc.start() {
393	                eprintln!("DAEMON_SERVICE_START_FALLBACK: {e} — starting a manual daemon");
394	                spawn_detached()?;
395	            }
396	        }
397	        crate::service::StartPlan::ManualSpawn => spawn_detached()?,
398	        // `running` was passed false, so AlreadyRunning is unreachable here.
399	        crate::service::StartPlan::AlreadyRunning => return Ok(()),
400	    }
401	    wait_until_up()
402	}
403
404	/// Outcome of a service-aware `daemon start` (REQ-DAEMON-6), so the CLI can
405	/// report truthfully what happened.
406	#[derive(Debug)]
407	pub enum StartOutcome {
408	    /// A daemon already answered — nothing started.
409	    AlreadyRunning,
410	    /// Started through the named service manager.
411	    ServiceStarted(&'static str),
412	    /// Spawned a manual detached daemon (no manager registered).
413	    Spawned,
414	}
415
416	/// Outcome of a service-aware `daemon stop` (REQ-DAEMON-6).
417	#[derive(Debug)]
418	pub enum StopOutcome {
419	    /// Nothing was running — already stopped.
420	    NotRunning,
421	    /// Stopped through the named service manager.
422	    ServiceStopped(&'static str),
423	    /// Stopped a manual daemon over the seed-control IPC.
424	    IpcStopped,
425	}
426
427	/// Service-aware `spt daemon start` (REQ-DAEMON-6): ensure the daemon is up in
428	/// the background, preferring the registered manager; idempotent + non-blocking
429	/// past the bind wait.
430	// [impl->REQ-DAEMON-6]
431	pub fn start_daemon() -> io::Result<StartOutcome> {
432	    let svc = crate::service::platform_service();
433	    match crate::service::plan_start(is_running(), svc.detected()) {
434	        crate::service::StartPlan::AlreadyRunning => Ok(StartOutcome::AlreadyRunning),
435	        crate::service::StartPlan::ServiceStart => match svc.start() {
436	            Ok(()) => {
437	                wait_until_up()?;
438	                Ok(StartOutcome::ServiceStarted(svc.label()))
439	            }
440	            // Manager present but undrivable (no session bus) — fall back to a
441	            // manual daemon so `daemon start` still brings one up.
442	            Err(e) => {
443	                eprintln!("DAEMON_SERVICE_START_FALLBACK: {e} — starting a manual daemon");
444	                spawn_detached()?;
445	                wait_until_up()?;
446	                Ok(StartOutcome::Spawned)
447	            }
448	        },
449	        crate::service::StartPlan::ManualSpawn => {
450	            spawn_detached()?;
451	            wait_until_up()?;
452	            Ok(StartOutcome::Spawned)
453	        }
454	    }
455	}
456
457	/// Service-aware `spt daemon stop` (REQ-DAEMON-6): a registered + active
458	/// service is stopped through its manager (so it does not auto-restart-fight);
459	/// a manual daemon takes the graceful IPC stop; nothing running is already
460	/// stopped. Idempotent.
461	// [impl->REQ-DAEMON-6]
462	pub fn stop_daemon() -> io::Result<StopOutcome> {
463	    let svc = crate::service::platform_service();
464	    match crate::service::plan_stop(is_running(), svc.detected(), svc.is_active()) {
465	        crate::service::StopPlan::NotRunning => Ok(StopOutcome::NotRunning),
466	        crate::service::StopPlan::ServiceStop => {
467	            svc.stop()?;
468	            Ok(StopOutcome::ServiceStopped(svc.label()))
469	        }
470	        crate::service::StopPlan::IpcStop => {
471	            request_stop()?;
472	            Ok(StopOutcome::IpcStopped)
473	        }
474	    }
475	}
476
477	/// The label of a managed service that is BOTH registered and active — a
478	/// conflict for an inline `daemon run` that is about to take the same socket
479	/// (REQ-DAEMON-7). `None` when no manager would fight.
480	// [impl->REQ-DAEMON-7]
481	pub fn active_service_conflict() -> Option<&'static str> {
482	    let svc = crate::service::platform_service();
483	    (svc.detected() && svc.is_active()).then(|| svc.label())
484	}
485
486	/// Spawn `spt daemon` as a detached background process. Inherits the environment
487	/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
488	/// socket names — but drops the std handles so it outlives the launching CLI
489	/// call. The child is independent (not killed when the parent exits): on unix it
490	/// is reparented to init when the launcher exits; on Windows a child is not in
491	/// the parent's job by default.
492	///
493	/// **Windows handle-inheritance guard (KNOWN-HAZARDS 5.6).** A caller may
494	/// capture this process's stdout/stderr through a pipe (`Command::output()`, a
495	/// harness hook reading the command's output). On Windows `CreateProcess` runs
496	/// with `bInheritHandles = TRUE`, so the long-lived daemon would inherit that
497	/// pipe's write end and hold it open forever — the capturing reader never sees
498	/// EOF and hangs. Worse, inheritable pipe handles flow through EVERY
499	/// `bInheritHandles = TRUE` hop: stripping only this process's *std* handles
500	/// (the first guard shipped here) still leaked a grandparent capture's pipe
501	/// sitting in our handle table as a stray. So detached long-lived children are
502	/// spawned with **`bInheritHandles = FALSE`** ([`detached_no_inherit`]) — zero
503	/// handles flow, whatever the ancestry. Unix is immune (Rust marks pipe fds
504	/// `CLOEXEC`) and keeps the plain `Command` path.
505	///
506	/// **Elevation guard (KNOWN-HAZARDS 5.7).** The daemon must ALWAYS run
507	/// unelevated in the invoking user's universe, whichever command spawns it:
508	/// an elevated spawner (Windows UAC, `sudo`) de-elevates the child here —
509	/// Windows via the UAC linked token ([`crate::deelevate::spawn_deelevated`];
510	/// no handle inheritance by construction, so 5.6 holds on that path too),
511	/// unix by dropping the child to `SUDO_UID`/`SUDO_GID` with the invoker's
512	/// `HOME`. When no unelevated identity exists (UAC off, genuine root), the
513	/// spawn proceeds as-is with a loud warning — a consistent universe, never a
514	/// torn one.
515	// [impl->REQ-HAZARD-ELEVATED-DAEMON-SPAWN]
516	fn spawn_detached() -> io::Result<()> {
517	    let exe = std::env::current_exe()?;
518	    #[cfg(windows)]
519	    {
520	        let exe = exe.to_string_lossy();
521	        let args = ["daemon".to_string(), "run".to_string()];
522	        match crate::deelevate::spawn_deelevated(&exe, &args) {
523	            Ok(Some(_pid)) => return Ok(()), // de-elevated child running
524	            Ok(None) => {
525	                if crate::deelevate::is_elevated() {
526	                    // No unelevated identity exists (UAC-off admin desktop,
527	                    // SYSTEM): the whole universe is uniformly elevated, so
528	                    // an elevated daemon is consistent there. Loud anyway.
529	                    eprintln!(
530	                        "ELEVATED_DAEMON: no unelevated identity to drop to — daemon inherits \
531	                         the elevated token"
532	                    );
533	                }
534	            }
535	            // A de-elevation TARGET existed but the spawn failed: an
536	            // elevated daemon here is a known-torn state (its pipes deny
537	            // the unelevated clients that target exists for). ABORT — do
538	            // not start a daemon that cannot serve (user-ratified
539	            // 2026-06-06: DEELEVATE_FAIL means spt won't function; refuse,
540	            // don't limp).
541	            Err(e) => {
542	                return Err(io::Error::other(format!(
543	                    "DEELEVATE_FAIL: {e} — refusing to start an ELEVATED daemon (it would deny \
544	                     unelevated clients); run spt from an unelevated shell"
545	                )));
546	            }
547	        }
548	        detached_no_inherit(&exe, &args)?;
549	        Ok(())
550	    }
551	    #[cfg(unix)]
552	    {
553	        use std::os::unix::process::CommandExt;
554	        let mut cmd = Command::new(exe);
555	        cmd.args(["daemon", "run"])
556	            .stdin(Stdio::null())
557	            .stdout(Stdio::null())
558	            .stderr(Stdio::null());
559	        if let Some(invoker) = crate::deelevate::daemon_target_user() {
560	            // Drop the child to the elected default user (else the sudo
561	            // invoker — M8 decision 8): std clears supplementary groups and
562	            // orders setgid-before-setuid when uid is set.
563	            // [impl->REQ-INSTALL-6]
564	            invoker.stamp_env(&mut cmd);
565	            cmd.uid(invoker.uid).gid(invoker.gid);
566	        }
567	        // setsid: detach into a NEW SESSION, not just a new process group —
568	        // a daemon auto-started inside an ssh/non-interactive session
569	        // otherwise dies with it (sshd HUPs the whole session on
570	        // disconnect; observed live in the 2026-06-06 fleet deploy, where
571	        // the freshly-spawned daemon vanished the moment the ssh command
572	        // returned). `process_group(0)` is NOT enough — the new group
573	        // still belongs to the dying session. Safety: this closure only
574	        // calls the async-signal-safe setsid(2).
575	        unsafe {
576	            cmd.pre_exec(|| {
577	                if libc::setsid() == -1 {
578	                    return Err(std::io::Error::last_os_error());
579	                }
580	                Ok(())
581	            });
582	        }
583	        cmd.spawn()?;
584	        Ok(())
585	    }
586	}
587
588	/// Spawn `program args…` detached with **`bInheritHandles = FALSE`** — the KH
589	/// 5.6 guard in its complete form: no handle (std or stray grandparent pipe)
590	/// flows into the immortal child, so no capturing caller anywhere up the
591	/// process chain can be wedged by it. `DETACHED_PROCESS` ⇒ no console, so the
592	/// child's std handles are simply absent (every such child runs stdio-null by
593	/// design anyway). `lpApplicationName` stays NULL so `program` resolves via
594	/// PATH like `Command` would. Shared by the daemon ([`spawn_detached`]) and
595	/// the shell binary ([`crate::shellhost::launch_shell`]). Raw `CreateProcessW`
596	/// declared inline to avoid a winapi dependency for one call.
597	// [impl->REQ-HAZARD-DETACHED-PIPE-INHERIT]
598	#[cfg(windows)]
599	pub(crate) fn detached_no_inherit(program: &str, args: &[String]) -> io::Result<u32> {
600	    use std::os::windows::ffi::OsStrExt;
601
602	    #[repr(C)]
603	    struct StartupInfoW {
604	        cb: u32,
605	        reserved: *mut u16,
606	        desktop: *mut u16,
607	        title: *mut u16,
608	        x: u32,
609	        y: u32,
610	        x_size: u32,
611	        y_size: u32,
612	        x_count_chars: u32,
613	        y_count_chars: u32,
614	        fill_attribute: u32,
615	        flags: u32,
616	        show_window: u16,
617	        cb_reserved2: u16,
618	        lp_reserved2: *mut u8,
619	        std_input: isize,
620	        std_output: isize,
621	        std_error: isize,
622	    }
623	    #[repr(C)]
624	    struct ProcessInformation {
625	        process: isize,
626	        thread: isize,
627	        pid: u32,
628	        tid: u32,
629	    }
630	    extern "system" {
631	        fn CreateProcessW(
632	            app: *const u16,
633	            cmdline: *mut u16,
634	            process_attrs: *mut core::ffi::c_void,
635	            thread_attrs: *mut core::ffi::c_void,
636	            inherit_handles: i32,
637	            flags: u32,
638	            env: *mut core::ffi::c_void,
639	            cwd: *const u16,
640	            startup: *mut StartupInfoW,
641	            out: *mut ProcessInformation,
642	        ) -> i32;
643	        fn CloseHandle(h: isize) -> i32;
644	    }
645	    // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
646	    // inherited console, no popup window, its own signal group.
647	    const FLAGS: u32 = 0x0000_0008 | 0x0000_0200 | 0x0800_0000;
648
649	    let mut cmdline = String::new();
650	    quote_windows_arg(&mut cmdline, program);
651	    for a in args {
652	        cmdline.push(' ');
653	        quote_windows_arg(&mut cmdline, a);
654	    }
655	    let mut cmdline_w: Vec<u16> = std::ffi::OsStr::new(&cmdline)
656	        .encode_wide()
657	        .chain(std::iter::once(0))
658	        .collect();
659	    let mut si: StartupInfoW = unsafe { std::mem::zeroed() };
660	    si.cb = std::mem::size_of::<StartupInfoW>() as u32;
661	    let mut pi: ProcessInformation = unsafe { std::mem::zeroed() };
662	    let ok = unsafe {
663	        CreateProcessW(
664	            std::ptr::null(),
665	            cmdline_w.as_mut_ptr(),
666	            std::ptr::null_mut(),
667	            std::ptr::null_mut(),
668	            0, // bInheritHandles = FALSE — the whole point.
669	            FLAGS,
670	            std::ptr::null_mut(), // inherit this process's environment
671	            std::ptr::null(),
672	            &mut si,
673	            &mut pi,
674	        )
675	    };
676	    if ok == 0 {
677	        return Err(io::Error::last_os_error());
678	    }
679	    unsafe {
680	        CloseHandle(pi.process);
681	        CloseHandle(pi.thread);
682	    }
683	    Ok(pi.pid)
684	}
685
686	/// Append `arg` to a Windows command line under `CommandLineToArgvW` quoting
687	/// rules (quote when needed; double preceding backslashes before an embedded
688	/// quote; double trailing backslashes before the closing quote).
689	// [impl->REQ-HAZARD-DETACHED-PIPE-INHERIT]
690	#[cfg(windows)]
691	pub(crate) fn quote_windows_arg(out: &mut String, arg: &str) {
692	    if !arg.is_empty() && !arg.chars().any(|c| c == ' ' || c == '\t' || c == '"') {
693	        out.push_str(arg);
694	        return;
695	    }
696	    out.push('"');
697	    let mut backslashes = 0usize;
698	    for c in arg.chars() {
699	        if c == '\\' {
700	            backslashes += 1;
701	        } else if c == '"' {
702	            for _ in 0..(backslashes * 2 + 1) {
703	                out.push('\\');
704	            }
705	            backslashes = 0;
706	            out.push('"');
707	        } else {
708	            for _ in 0..backslashes {
709	                out.push('\\');
710	            }
711	            backslashes = 0;
712	            out.push(c);
713	        }
714	    }
715	    for _ in 0..(backslashes * 2) {
716	        out.push('\\');
717	    }
718	    out.push('"');
719	}
720
721	#[cfg(test)]
722	mod tests {
723	    use super::*;
724	    use crate::seedmap::{serve_seed_control, SeedRegistry};
725	    use crate::test_home::with_home;
726
727	    // [unit->REQ-DAEMON-9] the net self-heal backoff ladder doubles each retry
728	    // and caps at NET_RETRY_CAP — so a long boot-race wait settles to a steady
729	    // 30s poll, never an ever-growing or zero-length spin.
730	    #[test]
731	    fn net_retry_backoff_doubles_then_caps() {
732	        assert_eq!(net_retry_backoff(NET_RETRY_FIRST), Duration::from_secs(2));
733	        assert_eq!(net_retry_backoff(Duration::from_secs(2)), Duration::from_secs(4));
734	        assert_eq!(net_retry_backoff(Duration::from_secs(16)), Duration::from_secs(30));
735	        assert_eq!(net_retry_backoff(NET_RETRY_CAP), NET_RETRY_CAP, "stays capped");
736	    }
737
738	    // [unit->REQ-HAZARD-DETACHED-PIPE-INHERIT] the no-inherit spawn's command
739	    // line obeys CommandLineToArgvW quoting: bare words pass through; spaces
740	    // quote; embedded quotes and the backslash-before-quote cases escape per
741	    // the rules (a wrong quoting would mis-tokenize the shell spawn template).
742	    #[cfg(windows)]
743	    #[test]
744	    fn windows_arg_quoting_roundtrips_the_rules() {
745	        let q = |arg: &str| {
746	            let mut s = String::new();
747	            quote_windows_arg(&mut s, arg);
748	            s
749	        };
750	        assert_eq!(q("daemon"), "daemon");
751	        assert_eq!(q(r"C:\bins\spt.exe"), r"C:\bins\spt.exe");
752	        assert_eq!(
753	            q(r"C:\Program Files\spt.exe"),
754	            r#""C:\Program Files\spt.exe""#
755	        );
756	        assert_eq!(q(""), r#""""#);
757	        assert_eq!(q(r#"say "hi""#), r#""say \"hi\"""#);
758	        assert_eq!(
759	            q(r"plain\back"),
760	            r"plain\back",
761	            "lone backslashes need no quoting"
762	        );
763	        assert_eq!(
764	            q(r"trail ing\"),
765	            r#""trail ing\\""#,
766	            "trailing backslash doubles"
767	        );
768	        assert_eq!(q(r#"back\"slash"#), r#""back\\\"slash""#);
769	    }
770
771	    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
772	    // seed-control server answers, it flips true and ensure_running() is a no-op
773	    // (the spawn branch — which would need the real `spt` binary — is not taken).
774	    #[test]
775	    fn is_running_tracks_the_seed_channel() {
776	        with_home(|_| {
777	            assert!(!is_running(), "no daemon bound yet");
778
779	            let reg = Arc::new(SeedRegistry::new());
780	            let name = seed_socket_name();
781	            let srv = Arc::clone(&reg);
782	            thread::spawn(move || {
783	                let _ = serve_seed_control(&name, srv);
784	            });
785	            for _ in 0..400 {
786	                if is_running() {
787	                    break;
788	                }
789	                thread::sleep(Duration::from_millis(5));
790	            }
791	            assert!(is_running(), "the seed channel answers → daemon is up");
792	            // ensure_running short-circuits (no real process spawn).
793	            ensure_running().expect("ensure is a no-op when already up");
794	        });
795	    }
796	}
797

//! The daemon process entrypoint + auto-start (B6).
//!
//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
//! of the process.
//!
//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
//! invocation calls it; if no daemon answers the seed-control ping it spawns
//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
//! against it is a true liveness test (`listen` is the interim model's anchor;
//! here the seed channel plays that role for cold-start detection).
// [impl->REQ-DAEMON-3]

use std::io;
#[cfg(unix)]
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};

use crate::broker::{default_journal_path, Broker};
use crate::config::{DaemonConfig, RelayChoice};
use crate::digesthub::{self, DigestHub};
use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
use crate::nethost::{NetConfig, NetHost};
use crate::seedmap::{self, SeedRegistry};

/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
const START_TIMEOUT: Duration = Duration::from_secs(5);
/// Poll cadence while waiting for the spawned daemon to come up.
const START_POLL: Duration = Duration::from_millis(25);

/// The per-machine broker/brain daemon process.
pub struct Daemon;

impl Daemon {
    /// Run the daemon: bind the broker (served on a background thread) + the
    /// seed-control channel (the foreground loop + liveness anchor) and serve
    /// until the process is killed. The body of `spt daemon`.
    pub fn run() -> io::Result<()> {
        // Entry elevation guard (KNOWN-HAZARDS 5.7): a DIRECTLY elevated
        // `spt daemon` (sudo / elevated shell) must not serve with the
        // elevated token — its pipes would deny unelevated clients (Windows)
        // or root the user's state universe (Linux). Unix drops privileges
        // in-process before any state is touched; Windows cannot drop a
        // token in-place, so it respawns de-elevated via the UAC linked
        // token and exits (the child re-enters here unelevated). With no
        // unelevated identity to drop to, serving continues as-is, loudly —
        // a consistent universe, never a torn one.
        // [impl->REQ-HAZARD-ELEVATED-DAEMON-SPAWN]
        // Drop target: the ELECTED default user when one exists, else the
        // sudo invoker — electing it on first use (M8 decision 8,
        // REQ-INSTALL-6: the first `sudo spt` decides; thereafter every
        // elevated launch lands in the same account's universe).
        // [impl->REQ-INSTALL-6]
        #[cfg(unix)]
        if let Some(invoker) = crate::deelevate::daemon_target_user() {
            match invoker.drop_in_process() {
                Ok(()) => eprintln!("DEELEVATED: daemon dropped to uid {}", invoker.uid),
                // A drop target existed but the drop failed: serving as
                // root would root the user's state universe — a known-torn
                // state. ABORT (user-ratified 2026-06-06: refuse, don't limp).
                Err(e) => {
                    return Err(io::Error::other(format!(
                        "DEELEVATE_FAIL: {e} — refusing to serve as root over a user universe; \
                         run spt unelevated"
                    )));
                }
            }
        }
        #[cfg(windows)]
        if crate::deelevate::is_elevated() {
            let exe = std::env::current_exe()?;
            match crate::deelevate::spawn_deelevated(
                &exe.to_string_lossy(),
                &["daemon".to_string(), "run".to_string()],
            ) {
                Ok(Some(pid)) => {
                    eprintln!(
                        "DEELEVATED_RESPAWN: unelevated daemon pid {pid}; elevated copy exits"
                    );
                    return Ok(());
                }
                // Uniformly-elevated universe (no unelevated identity):
                // serving elevated is consistent there. Loud anyway.
                Ok(None) => eprintln!(
                    "ELEVATED_DAEMON: no unelevated identity to drop to — serving with the \
                     elevated token"
                ),
                // Target existed, respawn failed: an elevated daemon is a
                // known-torn state. ABORT (user-ratified 2026-06-06).
                Err(e) => {
                    return Err(io::Error::other(format!(
                        "DEELEVATE_FAIL: {e} — refusing to serve with the elevated token; run \
                         spt from an unelevated shell"
                    )));
                }
            }
        }

        let reg = Arc::new(SeedRegistry::new());

        // Boot housekeeping (KNOWN-HAZARDS 1.3): sweep stale `index.lock`
        // wedges out of the git-touching runtime the daemon hosts — the
        // context store's seed repo + its agent/project worktrees (a crashed
        // git leaves a 0-byte lock that blocks every later commit forever).
        // Path composed via the layout authority without opening the full
        // ContextStore on the boot path; a lock-less (or store-less) home
        // no-ops.
        // [impl->REQ-HAZARD-STALE-INDEX-LOCK]
        for lock in spt_store::branchstore::sweep_stale_index_locks(
            &spt_store::contextstore::seed_git_dir(&spt_store::perch::tracked_dir()),
            spt_store::branchstore::STALE_LOCK_AGE,
        ) {
            eprintln!("STALE_LOCK_SWEPT:{}", lock.display());
        }

        // Serve-state boot reset (M8 decision 6, REQ-SUBNET-5): the live
        // attachment state starts from the config's startup defaults — an
        // unsaved attach/detach flip deliberately does not survive a daemon
        // restart (`--save` is the persistence path).
        // [impl->REQ-SUBNET-5]
        {
            let att = spt_store::attachment::AttachmentStore::reset_to(
                &DaemonConfig::load().detached_subnets,
            );
            if let Err(e) = att.save() {
                eprintln!("ATTACHMENT_RESET_FAIL: {e} — serving with all subnets attached");
            }
            for name in &att.detached {
                eprintln!("SUBNET_DETACHED:{name} (startup default — `spt subnet attach {name}`)");
            }
        }

        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
        // + live QUIC connections live in the broker so they survive a brain
        // restart. Identity is the durable node.key (stable name across daemon
        // restarts); relay stance comes from daemon.json; mDNS rides the
        // endpoint (closing the §B mDNS/relay-session row by construction). A
        // net failure degrades to a net-less broker — it must not cost PTY
        // hosting (mirrors the broker-bind soft-fail below).
        // [impl->REQ-DAEMON-2]
        // Identity (the durable node.key) decides node_hex independently of the
        // net bring-up: a NET_BIND_FAIL (e.g. the autostart beat the network up
        // at boot — REQ-DAEMON-9) is transient and retried, but a broken
        // identity is not, so we only arm the retry when identity is sound.
        let node_hex: Option<String> = spt_store::nodeid::load_or_create()
            .ok()
            .map(|i| i.public_key().to_hex());
        let net = if node_hex.is_some() {
            try_start_net()
        } else {
            eprintln!("NODE_KEY_FAIL: identity unavailable — broker runs net-less, no retry");
            None
        };

        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
        // background thread; a bind failure there must not stop the seed channel
        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
        let net_up = net.is_some();
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                let serve_broker = Arc::clone(&broker);
                thread::spawn(move || {
                    let _ = serve_broker.serve();
                });
                // Inbound net dispatch + outbound peer pump (D9-1): only
                // meaningful with a net host — a net-less broker accepts nothing
                // and dials nobody.
                let hex = node_hex.clone().unwrap_or_default();
                if net_up {
                    spawn_net_consumers(&hex);
                } else if node_hex.is_some() {
                    // Boot-race self-heal (REQ-DAEMON-9): net failed to bind but
                    // identity is sound — almost always the autostart racing the
                    // network up at boot. Retry net in the background; attach it
                    // to the broker + spawn the consumers when it succeeds, so
                    // the node becomes reachable WITHOUT a manual restart.
                    // [impl->REQ-DAEMON-9]
                    let broker = Arc::clone(&broker);
                    thread::spawn(move || net_retry_attach(broker, hex));
                }
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
        // the digest-control channel serves `spt digest <id>` snapshot pulls +
        // delta-stream subscribes against it. Served on a background thread like
        // the broker. The per-session feed that *populates* the hub is driven by
        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
        // sessions are spawned through the broker; a bare daemon serves the
        // channel and answers "no live digest" until a session is hosted.
        let digest_hub = Arc::new(DigestHub::new());
        {
            let hub = Arc::clone(&digest_hub);
            thread::spawn(move || {
                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
            });
        }

        // Shell wake-watchers (M5-D4b): the daemon hosts the offline half of
        // the shell online/offline exclusivity — a reconcile loop that runs
        // each offline instance's manifest `wake_command` as a supervised
        // watcher child (exit-opcode → wake resolution) and stops it when the
        // binary side wins. Boot sweep doubles as orphan cleanup after a
        // daemon crash. The daemon runs until killed, so the stop flag here
        // is held for symmetry with the dispatcher loops, never raised.
        // [impl->REQ-SHELL-2]
        {
            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
            let _ = crate::shellwake::spawn_wake_host(stop);
        }

        // Brain process supervisor (ADR-0018 Q2/Q3 — broker/brain process
        // restoration, D1 skeleton): the broker is the always-up per-machine
        // anchor; it spawns `spt daemon brain` as a supervised child and
        // respawns it on exit (an update is a planned brain self-exit on this
        // same path). D1 is a true minimal skeleton — the brain child connects,
        // signals ready, and idles; every existing loop above stays in THIS
        // (broker) process until D2 migrates it to a versioned verb. The stop
        // flag is held for symmetry with the other resident loops, never raised
        // (the daemon runs until killed). The brain child inherits this
        // process's already-unelevated token as a plain managed child.
        // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
        {
            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
            let _ = crate::brainproc::spawn_brain_supervisor(stop);
        }

        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
        // is the socket ping, never this file, so a stale value is harmless.
        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());

        // The seed-control channel is the foreground loop. Binding it is what
        // arbitrates the single-daemon-per-home invariant: a second daemon racing
        // to start fails this bind and exits, leaving the winner serving.
        seedmap::serve_seed_control(&seed_socket_name(), reg)
    }

    /// Run the brain child (`spt daemon brain`, hidden — ADR-0018 D1). The
    /// broker process spawns + supervises this; it connects to the broker over
    /// the existing socket IPC, signals ready, and idles. Not a user-facing
    /// entry — `spt daemon run` (the broker) launches it.
    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    pub fn run_brain() -> io::Result<()> {
        crate::brainproc::run_brain()
    }
}

/// Try to bring up the broker's network host once: build the production
/// `NetConfig` (durable node.key + relay/discovery/membership) and start the
/// endpoint, wiring on-connect roster propagation (REQ-MESH-2). `None` on a
/// `NET_BIND_FAIL` (e.g. the network/DNS stack not ready yet) or a missing
/// identity — the caller binds net-less and (REQ-DAEMON-9) retries.
// [impl->REQ-DAEMON-2]
// [impl->REQ-DAEMON-9]
fn try_start_net() -> Option<NetHost> {
    let cfg = DaemonConfig::load();
    let relay = match cfg.relay {
        RelayChoice::N0 => RelayPolicy::N0Default,
        RelayChoice::Disabled => RelayPolicy::Disabled,
    };
    let identity = spt_store::nodeid::load_or_create().ok()?;
    let net_cfg = NetConfig {
        identity,
        relay,
        discovery: LocalDiscovery::Mdns,
        scope: BindScope::All, // production: real interfaces
        // Mesh-D2: enforce seed-proof on every dial + accept, reading the live
        // subnet store each connect.
        membership: Some(crate::seedproofx::production_membership()),
    };
    match NetHost::start(net_cfg) {
        Ok(host) => {
            host.set_roster_exchange(crate::seedproofx::production_roster_exchange());
            Some(host)
        }
        Err(e) => {
            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
            None
        }
    }
}

/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump
/// (D9-1) — against the now-net-attached broker. Called once at boot (net up
/// immediately) OR once from the self-heal retry when net attaches later
/// (REQ-DAEMON-9). Self-contained: mints its own registry + stop flag.
// [impl->REQ-DAEMON-9]
fn spawn_net_consumers(node_hex: &str) {
    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_hex));
    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
    let _ = crate::dispatch::spawn_dispatcher(
        broker_socket_name(),
        Arc::clone(&registry),
        Arc::clone(&stop),
    );
    let cfg = DaemonConfig::load();
    if cfg.peer_pump {
        let _ = crate::peerloop::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);
    }
}

/// Background net self-heal supervisor (REQ-DAEMON-9): a daemon that came up
/// net-less (NET_BIND_FAIL at boot — typically the autostart racing the network
/// up) keeps retrying `try_start_net` with capped backoff and, on success,
/// ATTACHES the host to the live broker (`OnceLock`, so exactly once) and spawns
/// the net consumers. The node becomes reachable without a manual restart. Runs
/// until net binds — the network always arrives eventually after boot.
// [impl->REQ-DAEMON-9]
fn net_retry_attach(broker: Arc<Broker>, node_hex: String) {
    let mut backoff = NET_RETRY_FIRST;
    loop {
        thread::sleep(backoff);
        if let Some(host) = try_start_net() {
            if broker.attach_net(host) {
                eprintln!("NET_ATTACHED: net endpoint bound on retry — peer pump starting");
                spawn_net_consumers(&node_hex);
            }
            return;
        }
        backoff = net_retry_backoff(backoff);
        eprintln!("NET_BIND_RETRY: net still unavailable, retrying in {}s", backoff.as_secs());
    }
}

const NET_RETRY_FIRST: Duration = Duration::from_secs(1);
const NET_RETRY_CAP: Duration = Duration::from_secs(30);

/// The net self-heal backoff ladder (REQ-DAEMON-9): double each retry, capped
/// at [`NET_RETRY_CAP`]. Pure for tests.
fn net_retry_backoff(cur: Duration) -> Duration {
    (cur * 2).min(NET_RETRY_CAP)
}

/// Whether a daemon is already serving the seed-control channel for this home.
pub fn is_running() -> bool {
    seedmap::ping(&seed_socket_name()).is_ok()
}

/// Gracefully stop the daemon serving this home (`spt daemon stop`, M8
/// decision 5 / REQ-CLI-2): the seed-control stop op — acked, then the
/// daemon's foreground loop returns and the process exits cleanly.
// [impl->REQ-CLI-2]
pub fn request_stop() -> io::Result<()> {
    seedmap::request_stop(&seed_socket_name())
}

/// Block (bounded) until a daemon answers the seed-control channel, or time
/// out. Shared by every start path (`ensure_running`, `start_daemon`):
/// race-tolerant — if two callers spawn at once, one wins the bind and this
/// passes either way.
fn wait_until_up() -> io::Result<()> {
    let mut waited = Duration::ZERO;
    while waited < START_TIMEOUT {
        if is_running() {
            return Ok(());
        }
        thread::sleep(START_POLL);
        waited += START_POLL;
    }
    Err(io::Error::new(
        io::ErrorKind::TimedOut,
        "daemon did not come up within the start window",
    ))
}

/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if
/// one already answers; otherwise bring it up and wait (bounded) for the bind.
///
/// Service-aware (REQ-DAEMON-8): when an OS service manager has a registered
/// spt-daemon, this starts THAT — never a competing manual `spawn_detached`
/// daemon that would fight the service for the broker socket (the kitsubito
/// 2026-06-08 restart-loop). No manager ⇒ the detached spawn path as before.
// [impl->REQ-DAEMON-8]
pub fn ensure_running() -> io::Result<()> {
    if is_running() {
        return Ok(());
    }
    let svc = crate::service::platform_service();
    match crate::service::plan_start(false, svc.detected()) {
        crate::service::StartPlan::ServiceStart => {
            // Prefer the manager — but if it can't be driven (no session bus on
            // a default-home box, manager not running), fall back to a manual
            // daemon rather than leave the caller daemon-less. The autostart
            // path must never hard-fail when a spawn would have worked.
            if let Err(e) = svc.start() {
                eprintln!("DAEMON_SERVICE_START_FALLBACK: {e} — starting a manual daemon");
                spawn_detached()?;
            }
        }
        crate::service::StartPlan::ManualSpawn => spawn_detached()?,
        // `running` was passed false, so AlreadyRunning is unreachable here.
        crate::service::StartPlan::AlreadyRunning => return Ok(()),
    }
    wait_until_up()
}

/// Outcome of a service-aware `daemon start` (REQ-DAEMON-6), so the CLI can
/// report truthfully what happened.
#[derive(Debug)]
pub enum StartOutcome {
    /// A daemon already answered — nothing started.
    AlreadyRunning,
    /// Started through the named service manager.
    ServiceStarted(&'static str),
    /// Spawned a manual detached daemon (no manager registered).
    Spawned,
}

/// Outcome of a service-aware `daemon stop` (REQ-DAEMON-6).
#[derive(Debug)]
pub enum StopOutcome {
    /// Nothing was running — already stopped.
    NotRunning,
    /// Stopped through the named service manager.
    ServiceStopped(&'static str),
    /// Stopped a manual daemon over the seed-control IPC.
    IpcStopped,
}

/// Service-aware `spt daemon start` (REQ-DAEMON-6): ensure the daemon is up in
/// the background, preferring the registered manager; idempotent + non-blocking
/// past the bind wait.
// [impl->REQ-DAEMON-6]
pub fn start_daemon() -> io::Result<StartOutcome> {
    let svc = crate::service::platform_service();
    match crate::service::plan_start(is_running(), svc.detected()) {
        crate::service::StartPlan::AlreadyRunning => Ok(StartOutcome::AlreadyRunning),
        crate::service::StartPlan::ServiceStart => match svc.start() {
            Ok(()) => {
                wait_until_up()?;
                Ok(StartOutcome::ServiceStarted(svc.label()))
            }
            // Manager present but undrivable (no session bus) — fall back to a
            // manual daemon so `daemon start` still brings one up.
            Err(e) => {
                eprintln!("DAEMON_SERVICE_START_FALLBACK: {e} — starting a manual daemon");
                spawn_detached()?;
                wait_until_up()?;
                Ok(StartOutcome::Spawned)
            }
        },
        crate::service::StartPlan::ManualSpawn => {
            spawn_detached()?;
            wait_until_up()?;
            Ok(StartOutcome::Spawned)
        }
    }
}

/// Service-aware `spt daemon stop` (REQ-DAEMON-6): a registered + active
/// service is stopped through its manager (so it does not auto-restart-fight);
/// a manual daemon takes the graceful IPC stop; nothing running is already
/// stopped. Idempotent.
// [impl->REQ-DAEMON-6]
pub fn stop_daemon() -> io::Result<StopOutcome> {
    let svc = crate::service::platform_service();
    match crate::service::plan_stop(is_running(), svc.detected(), svc.is_active()) {
        crate::service::StopPlan::NotRunning => Ok(StopOutcome::NotRunning),
        crate::service::StopPlan::ServiceStop => {
            svc.stop()?;
            Ok(StopOutcome::ServiceStopped(svc.label()))
        }
        crate::service::StopPlan::IpcStop => {
            request_stop()?;
            Ok(StopOutcome::IpcStopped)
        }
    }
}

/// The label of a managed service that is BOTH registered and active — a
/// conflict for an inline `daemon run` that is about to take the same socket
/// (REQ-DAEMON-7). `None` when no manager would fight.
// [impl->REQ-DAEMON-7]
pub fn active_service_conflict() -> Option<&'static str> {
    let svc = crate::service::platform_service();
    (svc.detected() && svc.is_active()).then(|| svc.label())
}

/// Spawn `spt daemon` as a detached background process. Inherits the environment
/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
/// socket names — but drops the std handles so it outlives the launching CLI
/// call. The child is independent (not killed when the parent exits): on unix it
/// is reparented to init when the launcher exits; on Windows a child is not in
/// the parent's job by default.
///
/// **Windows handle-inheritance guard (KNOWN-HAZARDS 5.6).** A caller may
/// capture this process's stdout/stderr through a pipe (`Command::output()`, a
/// harness hook reading the command's output). On Windows `CreateProcess` runs
/// with `bInheritHandles = TRUE`, so the long-lived daemon would inherit that
/// pipe's write end and hold it open forever — the capturing reader never sees
/// EOF and hangs. Worse, inheritable pipe handles flow through EVERY
/// `bInheritHandles = TRUE` hop: stripping only this process's *std* handles
/// (the first guard shipped here) still leaked a grandparent capture's pipe
/// sitting in our handle table as a stray. So detached long-lived children are
/// spawned with **`bInheritHandles = FALSE`** ([`detached_no_inherit`]) — zero
/// handles flow, whatever the ancestry. Unix is immune (Rust marks pipe fds
/// `CLOEXEC`) and keeps the plain `Command` path.
///
/// **Elevation guard (KNOWN-HAZARDS 5.7).** The daemon must ALWAYS run
/// unelevated in the invoking user's universe, whichever command spawns it:
/// an elevated spawner (Windows UAC, `sudo`) de-elevates the child here —
/// Windows via the UAC linked token ([`crate::deelevate::spawn_deelevated`];
/// no handle inheritance by construction, so 5.6 holds on that path too),
/// unix by dropping the child to `SUDO_UID`/`SUDO_GID` with the invoker's
/// `HOME`. When no unelevated identity exists (UAC off, genuine root), the
/// spawn proceeds as-is with a loud warning — a consistent universe, never a
/// torn one.
// [impl->REQ-HAZARD-ELEVATED-DAEMON-SPAWN]
fn spawn_detached() -> io::Result<()> {
    let exe = std::env::current_exe()?;
    #[cfg(windows)]
    {
        let exe = exe.to_string_lossy();
        let args = ["daemon".to_string(), "run".to_string()];
        match crate::deelevate::spawn_deelevated(&exe, &args) {
            Ok(Some(_pid)) => return Ok(()), // de-elevated child running
            Ok(None) => {
                if crate::deelevate::is_elevated() {
                    // No unelevated identity exists (UAC-off admin desktop,
                    // SYSTEM): the whole universe is uniformly elevated, so
                    // an elevated daemon is consistent there. Loud anyway.
                    eprintln!(
                        "ELEVATED_DAEMON: no unelevated identity to drop to — daemon inherits \
                         the elevated token"
                    );
                }
            }
            // A de-elevation TARGET existed but the spawn failed: an
            // elevated daemon here is a known-torn state (its pipes deny
            // the unelevated clients that target exists for). ABORT — do
            // not start a daemon that cannot serve (user-ratified
            // 2026-06-06: DEELEVATE_FAIL means spt won't function; refuse,
            // don't limp).
            Err(e) => {
                return Err(io::Error::other(format!(
                    "DEELEVATE_FAIL: {e} — refusing to start an ELEVATED daemon (it would deny \
                     unelevated clients); run spt from an unelevated shell"
                )));
            }
        }
        detached_no_inherit(&exe, &args)?;
        Ok(())
    }
    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        let mut cmd = Command::new(exe);
        cmd.args(["daemon", "run"])
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        if let Some(invoker) = crate::deelevate::daemon_target_user() {
            // Drop the child to the elected default user (else the sudo
            // invoker — M8 decision 8): std clears supplementary groups and
            // orders setgid-before-setuid when uid is set.
            // [impl->REQ-INSTALL-6]
            invoker.stamp_env(&mut cmd);
            cmd.uid(invoker.uid).gid(invoker.gid);
        }
        // setsid: detach into a NEW SESSION, not just a new process group —
        // a daemon auto-started inside an ssh/non-interactive session
        // otherwise dies with it (sshd HUPs the whole session on
        // disconnect; observed live in the 2026-06-06 fleet deploy, where
        // the freshly-spawned daemon vanished the moment the ssh command
        // returned). `process_group(0)` is NOT enough — the new group
        // still belongs to the dying session. Safety: this closure only
        // calls the async-signal-safe setsid(2).
        unsafe {
            cmd.pre_exec(|| {
                if libc::setsid() == -1 {
                    return Err(std::io::Error::last_os_error());
                }
                Ok(())
            });
        }
        cmd.spawn()?;
        Ok(())
    }
}

/// Spawn `program args…` detached with **`bInheritHandles = FALSE`** — the KH
/// 5.6 guard in its complete form: no handle (std or stray grandparent pipe)
/// flows into the immortal child, so no capturing caller anywhere up the
/// process chain can be wedged by it. `DETACHED_PROCESS` ⇒ no console, so the
/// child's std handles are simply absent (every such child runs stdio-null by
/// design anyway). `lpApplicationName` stays NULL so `program` resolves via
/// PATH like `Command` would. Shared by the daemon ([`spawn_detached`]) and
/// the shell binary ([`crate::shellhost::launch_shell`]). Raw `CreateProcessW`
/// declared inline to avoid a winapi dependency for one call.
// [impl->REQ-HAZARD-DETACHED-PIPE-INHERIT]
#[cfg(windows)]
pub(crate) fn detached_no_inherit(program: &str, args: &[String]) -> io::Result<u32> {
    use std::os::windows::ffi::OsStrExt;

    #[repr(C)]
    struct StartupInfoW {
        cb: u32,
        reserved: *mut u16,
        desktop: *mut u16,
        title: *mut u16,
        x: u32,
        y: u32,
        x_size: u32,
        y_size: u32,
        x_count_chars: u32,
        y_count_chars: u32,
        fill_attribute: u32,
        flags: u32,
        show_window: u16,
        cb_reserved2: u16,
        lp_reserved2: *mut u8,
        std_input: isize,
        std_output: isize,
        std_error: isize,
    }
    #[repr(C)]
    struct ProcessInformation {
        process: isize,
        thread: isize,
        pid: u32,
        tid: u32,
    }
    extern "system" {
        fn CreateProcessW(
            app: *const u16,
            cmdline: *mut u16,
            process_attrs: *mut core::ffi::c_void,
            thread_attrs: *mut core::ffi::c_void,
            inherit_handles: i32,
            flags: u32,
            env: *mut core::ffi::c_void,
            cwd: *const u16,
            startup: *mut StartupInfoW,
            out: *mut ProcessInformation,
        ) -> i32;
        fn CloseHandle(h: isize) -> i32;
    }
    // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
    // inherited console, no popup window, its own signal group.
    const FLAGS: u32 = 0x0000_0008 | 0x0000_0200 | 0x0800_0000;

    let mut cmdline = String::new();
    quote_windows_arg(&mut cmdline, program);
    for a in args {
        cmdline.push(' ');
        quote_windows_arg(&mut cmdline, a);
    }
    let mut cmdline_w: Vec<u16> = std::ffi::OsStr::new(&cmdline)
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();
    let mut si: StartupInfoW = unsafe { std::mem::zeroed() };
    si.cb = std::mem::size_of::<StartupInfoW>() as u32;
    let mut pi: ProcessInformation = unsafe { std::mem::zeroed() };
    let ok = unsafe {
        CreateProcessW(
            std::ptr::null(),
            cmdline_w.as_mut_ptr(),
            std::ptr::null_mut(),
            std::ptr::null_mut(),
            0, // bInheritHandles = FALSE — the whole point.
            FLAGS,
            std::ptr::null_mut(), // inherit this process's environment
            std::ptr::null(),
            &mut si,
            &mut pi,
        )
    };
    if ok == 0 {
        return Err(io::Error::last_os_error());
    }
    unsafe {
        CloseHandle(pi.process);
        CloseHandle(pi.thread);
    }
    Ok(pi.pid)
}

/// Append `arg` to a Windows command line under `CommandLineToArgvW` quoting
/// rules (quote when needed; double preceding backslashes before an embedded
/// quote; double trailing backslashes before the closing quote).
// [impl->REQ-HAZARD-DETACHED-PIPE-INHERIT]
#[cfg(windows)]
pub(crate) fn quote_windows_arg(out: &mut String, arg: &str) {
    if !arg.is_empty() && !arg.chars().any(|c| c == ' ' || c == '\t' || c == '"') {
        out.push_str(arg);
        return;
    }
    out.push('"');
    let mut backslashes = 0usize;
    for c in arg.chars() {
        if c == '\\' {
            backslashes += 1;
        } else if c == '"' {
            for _ in 0..(backslashes * 2 + 1) {
                out.push('\\');
            }
            backslashes = 0;
            out.push('"');
        } else {
            for _ in 0..backslashes {
                out.push('\\');
            }
            backslashes = 0;
            out.push(c);
        }
    }
    for _ in 0..(backslashes * 2) {
        out.push('\\');
    }
    out.push('"');
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::seedmap::{serve_seed_control, SeedRegistry};
    use crate::test_home::with_home;

    // [unit->REQ-DAEMON-9] the net self-heal backoff ladder doubles each retry
    // and caps at NET_RETRY_CAP — so a long boot-race wait settles to a steady
    // 30s poll, never an ever-growing or zero-length spin.
    #[test]
    fn net_retry_backoff_doubles_then_caps() {
        assert_eq!(net_retry_backoff(NET_RETRY_FIRST), Duration::from_secs(2));
        assert_eq!(net_retry_backoff(Duration::from_secs(2)), Duration::from_secs(4));
        assert_eq!(net_retry_backoff(Duration::from_secs(16)), Duration::from_secs(30));
        assert_eq!(net_retry_backoff(NET_RETRY_CAP), NET_RETRY_CAP, "stays capped");
    }

    // [unit->REQ-HAZARD-DETACHED-PIPE-INHERIT] the no-inherit spawn's command
    // line obeys CommandLineToArgvW quoting: bare words pass through; spaces
    // quote; embedded quotes and the backslash-before-quote cases escape per
    // the rules (a wrong quoting would mis-tokenize the shell spawn template).
    #[cfg(windows)]
    #[test]
    fn windows_arg_quoting_roundtrips_the_rules() {
        let q = |arg: &str| {
            let mut s = String::new();
            quote_windows_arg(&mut s, arg);
            s
        };
        assert_eq!(q("daemon"), "daemon");
        assert_eq!(q(r"C:\bins\spt.exe"), r"C:\bins\spt.exe");
        assert_eq!(
            q(r"C:\Program Files\spt.exe"),
            r#""C:\Program Files\spt.exe""#
        );
        assert_eq!(q(""), r#""""#);
        assert_eq!(q(r#"say "hi""#), r#""say \"hi\"""#);
        assert_eq!(
            q(r"plain\back"),
            r"plain\back",
            "lone backslashes need no quoting"
        );
        assert_eq!(
            q(r"trail ing\"),
            r#""trail ing\\""#,
            "trailing backslash doubles"
        );
        assert_eq!(q(r#"back\"slash"#), r#""back\\\"slash""#);
    }

    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
    // seed-control server answers, it flips true and ensure_running() is a no-op
    // (the spawn branch — which would need the real `spt` binary — is not taken).
    #[test]
    fn is_running_tracks_the_seed_channel() {
        with_home(|_| {
            assert!(!is_running(), "no daemon bound yet");

            let reg = Arc::new(SeedRegistry::new());
            let name = seed_socket_name();
            let srv = Arc::clone(&reg);
            thread::spawn(move || {
                let _ = serve_seed_control(&name, srv);
            });
            for _ in 0..400 {
                if is_running() {
                    break;
                }
                thread::sleep(Duration::from_millis(5));
            }
            assert!(is_running(), "the seed channel answers → daemon is up");
            // ensure_running short-circuits (no real process spawn).
            ensure_running().expect("ensure is a no-op when already up");
        });
    }
}


Brain client is the IPC seam. D2 first migration = net consumers (dispatch + peer pump) into brain. Reading the relevant pieces.