Skip to main content

lean_ctx/core/intelligence_runtime/
cockpit.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The Cockpit web UI: a free, separately licensed, signed package kind.
3//!
4//! It travels through the runtime channel like the Intelligence Runtime and
5//! is authenticated just as strictly (manifest, signature, archive, catalog
6//! and delegation), but it is never licence-bound, lives in its own store
7//! (`cockpit-runtime[-production]`) with its own activation record, and is
8//! re-authenticated as a Cockpit on every start.
9//!
10//! One Cockpit per Engine dashboard process. It is started from a private
11//! copy of the authenticated bytes with a cleared environment and receives
12//! one JSON line on stdin (the start contract `leanctx.cockpit-start/v1`).
13//! It binds 127.0.0.1:0 and names its port as the first stdout line,
14//! `{"listening":<port>}`. The supervisor keeps stdin open for the Engine's
15//! lifetime; the Cockpit ends at stdin EOF, at the latest when this process
16//! ends. A crash is restarted on the next request after a bounded backoff.
17//! No token is ever logged.
18
19use std::io::{BufRead, BufReader, Read, Write};
20use std::path::{Path, PathBuf};
21use std::process::{Child, ChildStdin, Command, Stdio};
22use std::sync::{Mutex, PoisonError, mpsc};
23use std::time::{Duration, Instant};
24
25use serde::{Deserialize, Serialize};
26
27use super::{InstallError, Result, RuntimeKind, VerifiedPackage, bootstrap, install};
28
29/// The activation record in the Cockpit store: which installed package the
30/// dashboard starts, under which artifact key, and whether it is enabled.
31const ACTIVATION: &str = "activation.json";
32/// The name of the private copy the Cockpit runs as.
33const EXECUTABLE: &str = if cfg!(windows) {
34    "leanctx-cockpit.exe"
35} else {
36    "leanctx-cockpit"
37};
38/// How long a start may take to name its port.
39const HANDSHAKE: Duration = Duration::from_secs(10);
40/// How long a Cockpit may take to end after its stdin closed.
41const EXIT_GRACE: Duration = Duration::from_secs(2);
42/// Waits before the first, second and every further restart after a
43/// failure in a row. A Cockpit that ran for `STABLE` ends the row.
44const RESTART_BACKOFF: [Duration; 3] = [
45    Duration::from_secs(1),
46    Duration::from_secs(5),
47    Duration::from_secs(30),
48];
49const STABLE: Duration = Duration::from_mins(1);
50/// The port line is short; anything longer is not the start contract.
51const MAX_PORT_LINE: u64 = 256;
52
53/// What the dashboard knows about itself when it asks for the Cockpit.
54pub struct CockpitLaunch {
55    /// The port of the Engine dashboard on 127.0.0.1.
56    pub engine_port: u16,
57    /// The dashboard bearer token the Cockpit uses to call the Engine.
58    pub engine_token: String,
59    pub project_root: PathBuf,
60}
61
62/// Where the running Cockpit listens, and the token the dashboard's proxy
63/// presents to it.
64#[derive(Clone, PartialEq, Eq)]
65pub struct CockpitEndpoint {
66    pub port: u16,
67    /// 32 random bytes, lowercase hex.
68    pub cockpit_token: String,
69}
70
71impl std::fmt::Debug for CockpitEndpoint {
72    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        formatter
74            .debug_struct("CockpitEndpoint")
75            .field("port", &self.port)
76            .field("cockpit_token", &"<redacted>")
77            .finish()
78    }
79}
80
81/// Why no Cockpit is available; the dashboard shows the matching fallback.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum CockpitUnavailable {
84    /// No Cockpit is installed and activated in this host's Cockpit store.
85    NotInstalled,
86    /// The activation or the installed package did not authenticate.
87    VerificationFailed,
88    /// The authenticated Cockpit did not start, did not name its port in
89    /// time, or crashed and waits for its restart.
90    StartFailed,
91    /// The Cockpit is installed and was deactivated.
92    Disabled,
93    /// A source build: no Cockpit channel is compiled in and none is
94    /// installed. The Cockpit ships with the official installs.
95    SourceBuild,
96}
97
98impl CockpitUnavailable {
99    pub const fn as_str(self) -> &'static str {
100        match self {
101            Self::NotInstalled => "not_installed",
102            Self::VerificationFailed => "verification_failed",
103            Self::StartFailed => "start_failed",
104            Self::Disabled => "disabled",
105            Self::SourceBuild => "source_build",
106        }
107    }
108
109    /// One line for a user who opens the Cockpit while it cannot be served.
110    pub const fn message(self) -> &'static str {
111        match self {
112            Self::NotInstalled => {
113                "The Cockpit is not installed. Install it with: lean-ctx cockpit install"
114            }
115            Self::VerificationFailed => {
116                "The installed Cockpit did not verify and was not started. Reinstall it with: lean-ctx cockpit install"
117            }
118            Self::StartFailed => {
119                "The Cockpit did not start. It is retried automatically; in the terminal: lean-ctx gain"
120            }
121            Self::Disabled => {
122                "The Cockpit is deactivated. Activate it again with: lean-ctx cockpit install"
123            }
124            Self::SourceBuild => {
125                "Source build: the Cockpit ships with official installs (install.sh, Homebrew, npm, AUR); in the terminal: lean-ctx gain"
126            }
127        }
128    }
129}
130
131/// The five files of a Cockpit bundle and their size bounds.
132const BUNDLE_FILES: [(&str, usize); 5] = [
133    ("channel.json", 65_536),
134    ("channel.sig", 64),
135    ("manifest.json", 65_536),
136    ("manifest.sig", 64),
137    ("archive.tar.gz", super::manifest::MAX_ARCHIVE),
138];
139
140/// A bundle: a directory holding [`BUNDLE_FILES`], or a `.tar.gz` holding
141/// them directly or in one top-level directory. Nothing else is read.
142fn read_bundle(path: &Path) -> Result<super::catalog::Bundle> {
143    let mut files: [Option<Vec<u8>>; 5] = Default::default();
144    if std::fs::symlink_metadata(path)?.is_dir() {
145        for (slot, (name, limit)) in files.iter_mut().zip(BUNDLE_FILES) {
146            *slot = Some(super::read_regular(&path.join(name), limit)?);
147        }
148    } else {
149        let packed = super::read_regular(path, super::manifest::MAX_ARCHIVE + (1 << 20))?;
150        let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(packed.as_slice()));
151        for entry in archive.entries().map_err(|_| InstallError::Archive)? {
152            let entry = entry.map_err(|_| InstallError::Archive)?;
153            let kind = entry.header().entry_type();
154            if kind.is_dir() {
155                continue;
156            }
157            let entry_path = entry
158                .path()
159                .map_err(|_| InstallError::Archive)?
160                .into_owned();
161            let parts: Vec<_> = entry_path.components().collect();
162            if !kind.is_file()
163                || parts.len() > 2
164                || parts
165                    .iter()
166                    .any(|part| !matches!(part, std::path::Component::Normal(_)))
167            {
168                return Err(InstallError::Archive);
169            }
170            let name = entry_path.file_name().and_then(|name| name.to_str());
171            let Some(index) = BUNDLE_FILES
172                .iter()
173                .position(|(known, _)| Some(*known) == name)
174            else {
175                return Err(InstallError::Archive);
176            };
177            let limit = BUNDLE_FILES[index].1;
178            let mut bytes = Vec::new();
179            entry
180                .take(limit as u64 + 1)
181                .read_to_end(&mut bytes)
182                .map_err(|_| InstallError::Archive)?;
183            if bytes.len() > limit || files[index].replace(bytes).is_some() {
184                return Err(InstallError::Archive);
185            }
186        }
187    }
188    let [
189        Some(catalog),
190        Some(catalog_signature),
191        Some(manifest),
192        Some(signature),
193        Some(archive),
194    ] = files
195    else {
196        return Err(InstallError::Archive);
197    };
198    Ok(super::catalog::Bundle {
199        catalog,
200        catalog_signature,
201        archive,
202        manifest,
203        signature,
204    })
205}
206
207/// Verify `bundle` against this host's compiled-in Cockpit channel root,
208/// install it and activate it. No network. An older channel sequence than
209/// the active one installs only with `downgrade`.
210fn import(bundle: &super::catalog::Bundle, downgrade: bool) -> Result<serde_json::Value> {
211    let channel = bootstrap::cockpit_channel()?.ok_or(InstallError::Policy)?;
212    let root = bootstrap::cockpit_root()?;
213    install::prepare_parent_chain(root.parent().ok_or(InstallError::Directory)?)?;
214    let _guard = install::configuration_guard(&root)?;
215    let expected = install::active_manifest(&root)?;
216    let installation = super::catalog::import_kind(
217        RuntimeKind::Cockpit,
218        bundle,
219        &channel.key,
220        &root,
221        &expected,
222        downgrade,
223    )?;
224    activate_installation(&root, &installation, "bundle")
225}
226
227/// `lean-ctx cockpit install --from <dir|file.tar.gz> [--allow-downgrade]`.
228pub(crate) fn install_from(path: &Path, downgrade: bool) -> Result<serde_json::Value> {
229    import(&read_bundle(path)?, downgrade)
230}
231
232/// The `--from` form of `lean-ctx cockpit install` (`args` follow
233/// `install`); `false` when the arguments do not name one, so the caller
234/// installs from the channel. Exits non-zero on failure.
235pub fn install_from_cli(args: &[String]) -> bool {
236    if args.is_empty() {
237        return false;
238    }
239    let downgrade = args.iter().any(|arg| arg == "--allow-downgrade");
240    let rest: Vec<&str> = args
241        .iter()
242        .map(String::as_str)
243        .filter(|arg| *arg != "--allow-downgrade")
244        .collect();
245    let path = match rest.as_slice() {
246        ["--from", path] => *path,
247        [flag] if flag.starts_with("--from=") => &flag["--from=".len()..],
248        _ => {
249            eprintln!(
250                "Usage: lean-ctx cockpit install [--from <dir|file.tar.gz> [--allow-downgrade]]"
251            );
252            std::process::exit(2);
253        }
254    };
255    match install_from(Path::new(path), downgrade) {
256        Ok(result) => {
257            println!(
258                "{}",
259                serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string())
260            );
261            println!("Open it with: lean-ctx cockpit");
262        }
263        Err(InstallError::Downgrade) => {
264            eprintln!(
265                "lean-ctx cockpit install --from: this bundle is older than the active Cockpit; \
266                 add --allow-downgrade to install it"
267            );
268            std::process::exit(1);
269        }
270        Err(error) => {
271            eprintln!("lean-ctx cockpit install --from: the bundle was not installed ({error:?})");
272            std::process::exit(1);
273        }
274    }
275    true
276}
277
278/// The Cockpit bundle an official install ships beside the Engine.
279fn bundled_dir() -> Option<PathBuf> {
280    let exe = std::env::current_exe().ok()?.canonicalize().ok()?;
281    let prefix = exe.parent()?.parent()?;
282    ["share", "libexec"]
283        .iter()
284        .map(|place| prefix.join(place).join("lean-ctx").join("cockpit"))
285        .find(|dir| dir.join("manifest.json").is_file())
286}
287
288/// Import the shipped Cockpit when none is active or it ships a different
289/// package; one older than the active install (by channel sequence) is left
290/// alone. Never fails a start.
291fn import_bundled() {
292    let Some(dir) = bundled_dir() else {
293        return;
294    };
295    let Ok(manifest) = super::read_regular(&dir.join("manifest.json"), 65_536) else {
296        return;
297    };
298    let shipped = super::sha256(&manifest);
299    let active = bootstrap::cockpit_root()
300        .ok()
301        .and_then(|root| read_activation(&root).ok().flatten());
302    // The shipped package is active, or the user deactivated the Cockpit
303    // and keeps it deactivated.
304    if active
305        .as_ref()
306        .is_some_and(|active| !active.enabled || active.manifest_sha256 == shipped)
307    {
308        return;
309    }
310    match read_bundle(&dir).and_then(|bundle| import(&bundle, false)) {
311        Ok(_) => tracing::info!("imported the bundled cockpit"),
312        Err(InstallError::Downgrade) => {
313            tracing::info!("bundled cockpit is older than the active one; kept the active one");
314        }
315        Err(error) => tracing::debug!("bundled cockpit not imported: {error:?}"),
316    }
317}
318
319#[derive(Serialize, Deserialize)]
320#[serde(deny_unknown_fields)]
321struct Activation {
322    schema_version: u32,
323    enabled: bool,
324    manifest_sha256: String,
325    trust_key_hex: String,
326}
327
328fn read_activation(root: &Path) -> Result<Option<Activation>> {
329    let path = root.join(ACTIVATION);
330    if !path.try_exists()? {
331        return Ok(None);
332    }
333    let activation: Activation = serde_json::from_slice(&super::read_regular(&path, 4096)?)
334        .map_err(|_| InstallError::State)?;
335    if activation.schema_version != 1
336        || !super::is_digest(&activation.manifest_sha256, 64)
337        || !super::is_digest(&activation.trust_key_hex, 64)
338    {
339        return Err(InstallError::State);
340    }
341    Ok(Some(activation))
342}
343
344/// The caller holds the store's configuration guard.
345fn write_activation(root: &Path, activation: &Activation) -> Result<()> {
346    let bytes = serde_json::to_vec_pretty(activation).map_err(|_| InstallError::State)?;
347    crate::core::updater::atomic_write_bytes(&root.join(ACTIVATION), &bytes)
348        .map_err(|_| InstallError::Commit)
349}
350
351/// Authenticate the selected package as a Cockpit and record it as the one
352/// the dashboard starts. The caller holds the store's configuration guard.
353fn activate_locked(root: &Path, selected: &str, key_hex: &str) -> Result<serde_json::Value> {
354    let key = super::trust_key(key_hex)?;
355    let package = install::verified_active_kind(RuntimeKind::Cockpit, root, selected, &key)?;
356    write_activation(
357        root,
358        &Activation {
359            schema_version: 1,
360            enabled: true,
361            manifest_sha256: selected.to_owned(),
362            trust_key_hex: key_hex.to_owned(),
363        },
364    )?;
365    Ok(serde_json::json!({"status": "activated", "kind": "cockpit",
366        "receipt": package.receipt, "runtime_started": false, "release_approved": false}))
367}
368
369/// The activated Cockpit of this host, authenticated now.
370fn authenticated() -> std::result::Result<(PathBuf, VerifiedPackage), CockpitUnavailable> {
371    let root = bootstrap::cockpit_root().map_err(|_| CockpitUnavailable::NotInstalled)?;
372    let activation = match read_activation(&root) {
373        Ok(Some(activation)) => activation,
374        Ok(None) => return Err(CockpitUnavailable::NotInstalled),
375        Err(_) => return Err(CockpitUnavailable::VerificationFailed),
376    };
377    if !activation.enabled {
378        return Err(CockpitUnavailable::Disabled);
379    }
380    let package = super::trust_key(&activation.trust_key_hex)
381        .and_then(|key| {
382            install::verified_active_kind(
383                RuntimeKind::Cockpit,
384                &root,
385                &activation.manifest_sha256,
386                &key,
387            )
388        })
389        .map_err(|_| CockpitUnavailable::VerificationFailed)?;
390    Ok((root, package))
391}
392
393/// Download, verify, install and activate the Cockpit from this host's
394/// bundled Cockpit channel. `staging`, when given, must name that channel's
395/// track. Network access happens only here, on an explicit request.
396fn install_configured(staging: Option<bool>) -> Result<serde_json::Value> {
397    let channel = bootstrap::cockpit_channel()?.ok_or(InstallError::Policy)?;
398    if staging.is_some_and(|staging| staging != channel.staging) {
399        return Err(InstallError::Usage);
400    }
401    let root = bootstrap::cockpit_root()?;
402    install::prepare_parent_chain(root.parent().ok_or(InstallError::Directory)?)?;
403    let _guard = install::configuration_guard(&root)?;
404    let expected = install::active_manifest(&root)?;
405    let installation = super::catalog::sync_kind(
406        RuntimeKind::Cockpit,
407        channel.urls,
408        &channel.key,
409        &root,
410        &expected,
411    )?;
412    activate_installation(&root, &installation, "channel")
413}
414
415/// Activate what an install selected. The caller holds the store's
416/// configuration guard.
417fn activate_installation(
418    root: &Path,
419    installation: &serde_json::Value,
420    source: &str,
421) -> Result<serde_json::Value> {
422    let selected = installation["receipt"]["manifest_sha256"]
423        .as_str()
424        .ok_or(InstallError::State)?;
425    let key = installation["artifact_key_hex"]
426        .as_str()
427        .ok_or(InstallError::State)?;
428    let activation = activate_locked(root, selected, key)?;
429    retire();
430    Ok(
431        serde_json::json!({"status": "installed_and_activated", "kind": "cockpit", "source": source,
432        "installation": installation, "activation": activation, "release_approved": false}),
433    )
434}
435
436/// Install-on-demand for `lean-ctx cockpit`: the same path as
437/// `setup runtime sync-configured --kind cockpit`. The caller has the
438/// user's request to install; this downloads from the bundled channel.
439pub fn install_cockpit() -> std::result::Result<serde_json::Value, CockpitUnavailable> {
440    install_configured(None).map_err(|error| match error {
441        InstallError::Manifest
442        | InstallError::Signature
443        | InstallError::Archive
444        | InstallError::Size
445        | InstallError::State
446        | InstallError::Downgrade => CockpitUnavailable::VerificationFailed,
447        _ => CockpitUnavailable::NotInstalled,
448    })
449}
450
451/// Whether a Cockpit could be started now, decided exactly as a start
452/// decides it (shipped bundle imported first, then authenticated), without
453/// starting one and without network. Lets a command fall back to its
454/// terminal report before it opens a browser on the install page.
455pub fn availability() -> std::result::Result<(), CockpitUnavailable> {
456    import_bundled();
457    match authenticated() {
458        Ok(_) => Ok(()),
459        Err(CockpitUnavailable::NotInstalled)
460            if bootstrap::cockpit_channel().ok().flatten().is_none() =>
461        {
462            Err(CockpitUnavailable::SourceBuild)
463        }
464        Err(reason) => Err(reason),
465    }
466}
467
468/// `LEAN_CTX_NO_COCKPIT=1` (as for install.sh) keeps every automatic install off.
469fn installs_opted_out() -> bool {
470    std::env::var_os("LEAN_CTX_NO_COCKPIT").is_some_and(|value| !value.is_empty() && value != "0")
471}
472
473/// `availability`, but a missing Cockpit of an official build is installed
474/// first: the person asked to open it, and the Cockpit ships with LeanCTX.
475pub fn available_or_installed() -> std::result::Result<(), CockpitUnavailable> {
476    match availability() {
477        Err(CockpitUnavailable::NotInstalled) if !installs_opted_out() => {
478            eprintln!(
479                "Installing the LeanCTX Cockpit (signed, free) from the LeanCTX runtime channel…"
480            );
481            install_cockpit()?;
482            availability()
483        }
484        other => other,
485    }
486}
487
488/// After a failed automatic install, the next automatic attempt waits a day.
489const AUTO_RETRY: Duration = Duration::from_hours(24);
490
491fn auto_attempt_marker() -> Option<PathBuf> {
492    crate::core::data_dir::resolve_data_dir()
493        .ok()
494        .map(|dir| dir.join("cockpit-auto-install.failed"))
495}
496
497/// Whether a failed attempt recorded at `failed_at` still blocks one at `now`.
498fn retry_blocked(failed_at: Option<std::time::SystemTime>, now: std::time::SystemTime) -> bool {
499    failed_at
500        .and_then(|failed_at| now.duration_since(failed_at).ok())
501        .is_some_and(|elapsed| elapsed < AUTO_RETRY)
502}
503
504/// Install the Cockpit without being asked when an official build has none:
505/// after an update from a release without it, or an install path that ships
506/// only the Engine, nobody has to run a command. Only when none is installed
507/// (a deactivated Cockpit stays deactivated), outside CI, and after a failure
508/// at most once a day unless `retry_now`. Returns whether it installed one.
509pub(crate) fn install_if_missing(retry_now: bool) -> bool {
510    if installs_opted_out()
511        || crate::core::telemetry_consent::ci_detected(crate::core::host_env::var)
512    {
513        return false;
514    }
515    if !matches!(availability(), Err(CockpitUnavailable::NotInstalled)) {
516        return false;
517    }
518    let marker = auto_attempt_marker();
519    let failed_at = marker
520        .as_deref()
521        .and_then(|path| std::fs::metadata(path).ok())
522        .and_then(|metadata| metadata.modified().ok());
523    if !retry_now && retry_blocked(failed_at, std::time::SystemTime::now()) {
524        return false;
525    }
526    match install_cockpit() {
527        Ok(_) => {
528            if let Some(path) = &marker {
529                let _ = std::fs::remove_file(path);
530            }
531            tracing::info!("installed the cockpit automatically");
532            true
533        }
534        Err(reason) => {
535            if let Some(path) = &marker {
536                let _ = std::fs::write(path, reason.as_str());
537            }
538            tracing::debug!("automatic cockpit install failed: {}", reason.as_str());
539            false
540        }
541    }
542}
543
544/// `install_if_missing` on its own thread: an MCP or daemon start never waits
545/// for the download.
546pub fn install_if_missing_in_background() {
547    let _ = std::thread::Builder::new()
548        .name("cockpit-install".into())
549        .spawn(|| {
550            install_if_missing(false);
551        });
552}
553
554/// What `setup runtime status --kind cockpit` reports; starts nothing and
555/// uses no network.
556pub(crate) fn status() -> serde_json::Value {
557    let (status, version) = match authenticated() {
558        Ok((_, package)) => ("installed", Some(package.receipt.version)),
559        Err(reason) => (reason.as_str(), None),
560    };
561    let channel = bootstrap::cockpit_channel().ok().flatten();
562    serde_json::json!({"schema_version": 1, "kind": "cockpit", "status": status,
563        "version": version, "channel_available": channel.is_some(),
564        "staging": channel.map(|channel| channel.staging),
565        "runtime_started": false, "release_approved": false})
566}
567
568fn deactivate() -> Result<serde_json::Value> {
569    let root = bootstrap::cockpit_root()?;
570    if !root.try_exists()? {
571        return Ok(serde_json::json!({"status": "not_installed", "kind": "cockpit"}));
572    }
573    let _guard = install::configuration_guard(&root)?;
574    let Some(mut activation) = read_activation(&root)? else {
575        return Ok(serde_json::json!({"status": "not_installed", "kind": "cockpit"}));
576    };
577    activation.enabled = false;
578    write_activation(&root, &activation)?;
579    retire();
580    Ok(serde_json::json!({"status": "disabled", "kind": "cockpit", "release_approved": false}))
581}
582
583/// `lean-ctx setup runtime <operation> --kind cockpit`: the Cockpit's own
584/// `status`, `sync-configured`, `activate` and `deactivate`. Package
585/// operations (`verify`, `install`, `download`, `sync`) run the shared path.
586pub(super) fn run_cli(
587    operation: &str,
588    staging: bool,
589    accepted: bool,
590    flags: &std::collections::BTreeMap<&str, &str>,
591) -> Result<serde_json::Value> {
592    match operation {
593        "status" if flags.is_empty() => Ok(status()),
594        "deactivate" if flags.is_empty() => deactivate(),
595        "sync-configured" if flags.is_empty() && accepted => install_configured(Some(staging)),
596        // Explicit staging activation of a package installed with
597        // `install --staging --kind cockpit` into this host's Cockpit store.
598        "activate" if staging && accepted && flags.len() == 4 => {
599            let get = |name| flags.get(name).copied().ok_or(InstallError::Usage);
600            let root = Path::new(get("--root")?);
601            let selected = get("--manifest-sha256")?;
602            if get("--expected-active")? != selected
603                || root != bootstrap::cockpit_root()?
604                || bootstrap::production_key(root)?.is_some()
605            {
606                return Err(InstallError::Usage);
607            }
608            let _guard = install::configuration_guard(root)?;
609            let result = activate_locked(root, selected, get("--trust-key-hex")?)?;
610            retire();
611            Ok(result)
612        }
613        _ => Err(InstallError::Usage),
614    }
615}
616
617/// One started Cockpit process: its stdin stays open while it runs.
618pub(super) struct Process {
619    child: Child,
620    stdin: Option<ChildStdin>,
621    port: u16,
622}
623
624impl Process {
625    /// Close stdin, give the Cockpit `grace` to end, then kill and collect
626    /// it. True when it ended by itself. Repeated calls do nothing.
627    pub(super) fn stop(&mut self, grace: Duration) -> bool {
628        drop(self.stdin.take());
629        let deadline = Instant::now() + grace;
630        loop {
631            match self.child.try_wait() {
632                Ok(Some(_)) => return true,
633                Ok(None) if Instant::now() < deadline => {
634                    std::thread::sleep(Duration::from_millis(20));
635                }
636                _ => {
637                    let _ = self.child.kill();
638                    let _ = self.child.wait();
639                    return false;
640                }
641            }
642        }
643    }
644
645    fn running(&mut self) -> bool {
646        matches!(self.child.try_wait(), Ok(None))
647    }
648}
649
650impl Drop for Process {
651    fn drop(&mut self) {
652        self.stop(EXIT_GRACE);
653    }
654}
655
656/// Spawn `command`, hand it the start line and read its port within
657/// `deadline`. A start that does not name its port in time is killed.
658pub(super) fn handshake(
659    mut command: Command,
660    start: &[u8],
661    deadline: Duration,
662) -> std::result::Result<Process, CockpitUnavailable> {
663    command
664        .stdin(Stdio::piped())
665        .stdout(Stdio::piped())
666        // Nothing the Cockpit writes there is shown; it could repeat a token.
667        .stderr(Stdio::null());
668    let child = command
669        .spawn()
670        .map_err(|_| CockpitUnavailable::StartFailed)?;
671    let mut process = Process {
672        child,
673        stdin: None,
674        port: 0,
675    };
676    let (Some(mut stdin), Some(stdout)) = (process.child.stdin.take(), process.child.stdout.take())
677    else {
678        process.stop(Duration::ZERO);
679        return Err(CockpitUnavailable::StartFailed);
680    };
681    if stdin.write_all(start).and_then(|()| stdin.flush()).is_err() {
682        process.stop(Duration::ZERO);
683        return Err(CockpitUnavailable::StartFailed);
684    }
685    process.stdin = Some(stdin);
686    let (sender, receiver) = mpsc::sync_channel(1);
687    let reader = std::thread::Builder::new()
688        .name("cockpit-output".to_owned())
689        .spawn(move || {
690            let mut output = BufReader::new(stdout);
691            let mut line = Vec::new();
692            let read = (&mut output)
693                .take(MAX_PORT_LINE)
694                .read_until(b'\n', &mut line);
695            let _ = sender.send(read.ok().map(|_| line));
696            // Drained to its end, so the Cockpit never blocks on a full pipe.
697            let _ = std::io::copy(&mut output, &mut std::io::sink());
698        });
699    let port = reader
700        .ok()
701        .and_then(|_| receiver.recv_timeout(deadline).ok().flatten())
702        .and_then(|line| listening(&line));
703    if let Some(port) = port {
704        process.port = port;
705        Ok(process)
706    } else {
707        process.stop(Duration::ZERO);
708        Err(CockpitUnavailable::StartFailed)
709    }
710}
711
712/// The port of a terminated `{"listening":<port>}` line.
713fn listening(line: &[u8]) -> Option<u16> {
714    #[derive(Deserialize)]
715    #[serde(deny_unknown_fields)]
716    struct Listening {
717        listening: u16,
718    }
719    if line.last() != Some(&b'\n') {
720        return None;
721    }
722    serde_json::from_slice::<Listening>(line)
723        .ok()
724        .map(|listening| listening.listening)
725        .filter(|port| *port != 0)
726}
727
728struct Running {
729    process: Process,
730    /// The private directory the authenticated binary runs from.
731    prepared: Option<super::health::Prepared>,
732    cockpit_token: String,
733    launch: [u8; 32],
734    started: Instant,
735}
736
737impl Drop for Running {
738    fn drop(&mut self) {
739        self.process.stop(EXIT_GRACE);
740        if let Some(prepared) = self.prepared.take() {
741            let _ = prepared.close();
742        }
743    }
744}
745
746struct State {
747    running: Option<Running>,
748    /// Failures in a row.
749    failures: usize,
750    retry_at: Option<Instant>,
751    last_failure: CockpitUnavailable,
752}
753
754static STATE: Mutex<State> = Mutex::new(State {
755    running: None,
756    failures: 0,
757    retry_at: None,
758    last_failure: CockpitUnavailable::StartFailed,
759});
760
761fn launch_key(launch: &CockpitLaunch) -> [u8; 32] {
762    let mut hasher = blake3::Hasher::new();
763    hasher.update(&launch.engine_port.to_le_bytes());
764    hasher.update(&(launch.engine_token.len() as u64).to_le_bytes());
765    hasher.update(launch.engine_token.as_bytes());
766    hasher.update(launch.project_root.as_os_str().as_encoded_bytes());
767    *hasher.finalize().as_bytes()
768}
769
770/// The running Cockpit of this process, started when none runs. Holds the
771/// supervisor lock across a start, so concurrent callers share one start.
772pub fn cockpit_endpoint(
773    launch: &CockpitLaunch,
774) -> std::result::Result<CockpitEndpoint, CockpitUnavailable> {
775    let key = launch_key(launch);
776    let mut state = STATE.lock().unwrap_or_else(PoisonError::into_inner);
777    let now = Instant::now();
778    if let Some(running) = state.running.as_mut() {
779        if running.process.running() {
780            if running.launch == key {
781                return Ok(CockpitEndpoint {
782                    port: running.process.port,
783                    cockpit_token: running.cockpit_token.clone(),
784                });
785            }
786            // Started for another dashboard: replaced, nothing failed.
787            state.running = None;
788        } else {
789            let stable = running.started.elapsed() >= STABLE;
790            state.running = None;
791            if stable {
792                state.failures = 0;
793            }
794            tracing::warn!("cockpit exited; restarting after backoff");
795            fail(&mut state, CockpitUnavailable::StartFailed, now);
796        }
797    }
798    if state.retry_at.is_some_and(|at| now < at) {
799        return Err(state.last_failure);
800    }
801    match start(launch, key) {
802        Ok(running) => {
803            let endpoint = CockpitEndpoint {
804                port: running.process.port,
805                cockpit_token: running.cockpit_token.clone(),
806            };
807            state.retry_at = None;
808            state.running = Some(running);
809            Ok(endpoint)
810        }
811        // Nothing to retry: the user installs or activates it.
812        Err(
813            reason @ (CockpitUnavailable::NotInstalled
814            | CockpitUnavailable::Disabled
815            | CockpitUnavailable::SourceBuild),
816        ) => Err(reason),
817        Err(reason) => {
818            tracing::warn!("cockpit not started: {}", reason.as_str());
819            fail(&mut state, reason, now);
820            Err(reason)
821        }
822    }
823}
824
825fn fail(state: &mut State, reason: CockpitUnavailable, now: Instant) {
826    let wait = RESTART_BACKOFF[state.failures.min(RESTART_BACKOFF.len() - 1)];
827    state.failures = state.failures.saturating_add(1);
828    state.retry_at = Some(now + wait);
829    state.last_failure = reason;
830}
831
832/// Stop this process's Cockpit, if any; the next request starts the
833/// currently activated package without waiting for a backoff.
834pub fn retire() {
835    let mut state = STATE.lock().unwrap_or_else(PoisonError::into_inner);
836    state.running = None;
837    state.failures = 0;
838    state.retry_at = None;
839}
840
841fn start(
842    launch: &CockpitLaunch,
843    key: [u8; 32],
844) -> std::result::Result<Running, CockpitUnavailable> {
845    import_bundled();
846    let (root, package) = match authenticated() {
847        Err(CockpitUnavailable::NotInstalled)
848            if bootstrap::cockpit_channel().ok().flatten().is_none() =>
849        {
850            return Err(CockpitUnavailable::SourceBuild);
851        }
852        other => other?,
853    };
854    let mut token = [0_u8; 32];
855    getrandom::fill(&mut token).map_err(|_| CockpitUnavailable::StartFailed)?;
856    let cockpit_token = hex::encode(token);
857    let start_line = start_line(launch, &cockpit_token).ok_or(CockpitUnavailable::StartFailed)?;
858    let (prepared, command) = super::health::prepare_binary(&package.binary, &root, EXECUTABLE)
859        .map_err(|_| CockpitUnavailable::StartFailed)?;
860    match handshake(command, &start_line, HANDSHAKE) {
861        Ok(process) => Ok(Running {
862            process,
863            prepared: Some(prepared),
864            cockpit_token,
865            launch: key,
866            started: Instant::now(),
867        }),
868        Err(reason) => {
869            let _ = prepared.close();
870            Err(reason)
871        }
872    }
873}
874
875/// The one start line of contract `leanctx.cockpit-start/v1`.
876fn start_line(launch: &CockpitLaunch, cockpit_token: &str) -> Option<Vec<u8>> {
877    let engine_exe = std::env::current_exe().ok()?;
878    // The Cockpit runs with a cleared environment: these absolute paths,
879    // resolved exactly as the Engine resolves them, are how it finds them.
880    let data_dir = crate::core::data_dir::resolve_data_dir().ok()?;
881    let state_dir = crate::core::paths::state_dir().ok()?;
882    if !data_dir.is_absolute() || !state_dir.is_absolute() {
883        return None;
884    }
885    // Optional: the host session logs it reads live under the user's home.
886    let home_dir = dirs::home_dir()
887        .filter(|home| home.is_absolute())
888        .and_then(|home| home.to_str().map(str::to_owned));
889    // Optional: Engine stores it reads (e.g. path_mode_memory.json) live here.
890    let cache_dir = crate::core::paths::cache_dir()
891        .ok()
892        .filter(|cache| cache.is_absolute())
893        .and_then(|cache| cache.to_str().map(str::to_owned));
894    // Optional: where the Engine reads its config, and the PATH its own CLI
895    // calls (`gh`, `git`) resolve against.
896    let config_dir = crate::core::paths::config_dir_read_only()
897        .ok()
898        .filter(|config| config.is_absolute())
899        .and_then(|config| config.to_str().map(str::to_owned));
900    let search_path = std::env::var("PATH").ok();
901    let mut line = serde_json::to_vec(&serde_json::json!({
902        "home_dir": home_dir,
903        "engine_cache_dir": cache_dir,
904        "engine_config_dir": config_dir,
905        "search_path": search_path,
906        "cockpit_token": cockpit_token,
907        "engine_url": format!("http://127.0.0.1:{}", launch.engine_port),
908        "engine_token": launch.engine_token,
909        "engine_exe": engine_exe.to_str()?,
910        "project_root": launch.project_root.to_str()?,
911        "data_dir": data_dir.to_str()?,
912        "engine_state_dir": state_dir.to_str()?,
913        "engine_version": env!("CARGO_PKG_VERSION"),
914    }))
915    .ok()?;
916    line.push(b'\n');
917    Some(line)
918}
919
920#[cfg(all(test, unix))]
921mod tests {
922    use super::*;
923
924    fn script(body: &str) -> Command {
925        let mut command = Command::new("/bin/sh");
926        command.arg("-c").arg(body);
927        command
928    }
929
930    #[test]
931    fn handshake_reads_the_port_keeps_stdin_open_and_the_cockpit_ends_at_eof() {
932        let directory = tempfile::tempdir().unwrap();
933        let path = directory.path().display();
934        let mut process = handshake(
935            script(&format!(
936                "IFS= read -r line; printf '%s' \"$line\" > '{path}/start'; \
937                 echo '{{\"listening\":4242}}'; cat > /dev/null; touch '{path}/eof'"
938            )),
939            b"{\"cockpit_token\":\"t\"}\n",
940            Duration::from_secs(10),
941        )
942        .unwrap();
943        assert_eq!(process.port, 4242);
944        assert_eq!(
945            std::fs::read_to_string(directory.path().join("start")).unwrap(),
946            "{\"cockpit_token\":\"t\"}"
947        );
948        // Alive while the supervisor holds stdin, not merely not yet ended.
949        std::thread::sleep(Duration::from_millis(300));
950        assert!(process.running());
951        assert!(!directory.path().join("eof").exists());
952        assert!(
953            process.stop(Duration::from_secs(5)),
954            "ended by itself at EOF"
955        );
956        assert!(directory.path().join("eof").exists());
957    }
958
959    fn tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
960        let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
961            Vec::new(),
962            flate2::Compression::fast(),
963        ));
964        for (path, bytes) in entries {
965            let mut header = tar::Header::new_gnu();
966            header.set_size(bytes.len() as u64);
967            header.set_mode(0o644);
968            header.set_cksum();
969            builder.append_data(&mut header, path, *bytes).unwrap();
970        }
971        builder.into_inner().unwrap().finish().unwrap()
972    }
973
974    #[test]
975    fn a_bundle_is_read_from_its_directory_or_tarball_and_nothing_else() {
976        let directory = tempfile::tempdir().unwrap();
977        let files: Vec<(&str, Vec<u8>)> = BUNDLE_FILES
978            .iter()
979            .map(|(name, _)| (*name, name.as_bytes().to_vec()))
980            .collect();
981        let bundle = directory.path().join("lean-ctx-cockpit");
982        std::fs::create_dir(&bundle).unwrap();
983        for (name, bytes) in &files {
984            std::fs::write(bundle.join(name), bytes).unwrap();
985        }
986        let from_dir = read_bundle(&bundle).unwrap();
987        assert_eq!(
988            (from_dir.catalog.as_slice(), from_dir.archive.as_slice()),
989            (b"channel.json".as_slice(), b"archive.tar.gz".as_slice())
990        );
991
992        let tarball = directory.path().join("bundle.tar.gz");
993        let nested: Vec<(String, &[u8])> = files
994            .iter()
995            .map(|(name, bytes)| (format!("lean-ctx-cockpit/{name}"), bytes.as_slice()))
996            .collect();
997        let entries: Vec<(&str, &[u8])> = nested.iter().map(|(p, b)| (p.as_str(), *b)).collect();
998        std::fs::write(&tarball, tar_gz(&entries)).unwrap();
999        let from_tar = read_bundle(&tarball).unwrap();
1000        assert_eq!(from_tar.manifest, b"manifest.json");
1001
1002        // A missing, unknown, duplicated, oversized or deeper member is refused.
1003        let refused: [Vec<(&str, &[u8])>; 5] = [
1004            entries[..4].to_vec(),
1005            [
1006                entries.clone(),
1007                vec![("lean-ctx-cockpit/extra", b"x".as_slice())],
1008            ]
1009            .concat(),
1010            [
1011                entries.clone(),
1012                vec![("other/channel.json", b"y".as_slice())],
1013            ]
1014            .concat(),
1015            [
1016                vec![entries[0]],
1017                entries[2..].to_vec(),
1018                vec![("lean-ctx-cockpit/channel.sig", [0_u8; 65].as_slice())],
1019            ]
1020            .concat(),
1021            [
1022                entries[1..].to_vec(),
1023                vec![("a/b/channel.json", b"z".as_slice())],
1024            ]
1025            .concat(),
1026        ];
1027        for members in refused {
1028            std::fs::write(&tarball, tar_gz(&members)).unwrap();
1029            assert!(read_bundle(&tarball).is_err());
1030        }
1031    }
1032
1033    #[test]
1034    fn a_start_that_never_names_its_port_fails_within_the_deadline() {
1035        for body in ["exec sleep 30", "echo 'listening on 4242'; exec sleep 30"] {
1036            let begun = Instant::now();
1037            let refused = handshake(script(body), b"{}\n", Duration::from_millis(300));
1038            assert_eq!(refused.err(), Some(CockpitUnavailable::StartFailed));
1039            assert!(begun.elapsed() < Duration::from_secs(5), "{body}");
1040        }
1041    }
1042
1043    #[test]
1044    fn a_failed_automatic_install_is_retried_after_a_day() {
1045        let now = std::time::SystemTime::now();
1046        assert!(!retry_blocked(None, now));
1047        assert!(retry_blocked(Some(now - Duration::from_secs(60)), now));
1048        assert!(retry_blocked(
1049            Some(now - AUTO_RETRY + Duration::from_secs(1)),
1050            now
1051        ));
1052        assert!(!retry_blocked(Some(now - AUTO_RETRY), now));
1053        // A clock that went backwards never blocks.
1054        assert!(!retry_blocked(Some(now + Duration::from_secs(60)), now));
1055    }
1056
1057    #[test]
1058    fn no_cockpit_opt_out_and_a_source_build_install_nothing() {
1059        let _data = crate::core::data_dir::isolated_data_dir();
1060        let _off = crate::test_env::VarGuard::set("LEAN_CTX_NO_COCKPIT", "1");
1061        assert!(installs_opted_out());
1062        assert!(!install_if_missing(true));
1063        drop(_off);
1064        let _on = crate::test_env::VarGuard::set("LEAN_CTX_NO_COCKPIT", "0");
1065        assert!(!installs_opted_out());
1066        // A test build carries no Cockpit channel: nothing to install from.
1067        assert!(!install_if_missing(true));
1068    }
1069}