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