1use 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
29const ACTIVATION: &str = "activation.json";
32const EXECUTABLE: &str = if cfg!(windows) {
34 "leanctx-cockpit.exe"
35} else {
36 "leanctx-cockpit"
37};
38const HANDSHAKE: Duration = Duration::from_secs(10);
40const EXIT_GRACE: Duration = Duration::from_secs(2);
42const 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);
50const MAX_PORT_LINE: u64 = 256;
52
53pub struct CockpitLaunch {
55 pub engine_port: u16,
57 pub engine_token: String,
59 pub project_root: PathBuf,
60}
61
62#[derive(Clone, PartialEq, Eq)]
65pub struct CockpitEndpoint {
66 pub port: u16,
67 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum CockpitUnavailable {
84 NotInstalled,
86 VerificationFailed,
88 StartFailed,
91 Disabled,
93 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 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
133const 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
142fn 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
209fn 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
229pub(crate) fn install_from(path: &Path, downgrade: bool) -> Result<serde_json::Value> {
231 import(&read_bundle(path)?, downgrade)
232}
233
234pub 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
280fn 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
290fn 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 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
346fn 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
353fn 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
371fn 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
395fn 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
417fn 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
438pub 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
453pub 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
470fn installs_opted_out() -> bool {
472 std::env::var_os("LEAN_CTX_NO_COCKPIT").is_some_and(|value| !value.is_empty() && value != "0")
473}
474
475pub 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
490const 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
499fn 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
506pub(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
546pub 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
556pub(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
585pub(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 "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
619pub(super) struct Process {
621 child: Child,
622 stdin: Option<ChildStdin>,
623 port: u16,
624}
625
626impl Process {
627 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
658pub(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 .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 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
726fn 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 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: 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
784pub 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 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 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
846pub 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
892fn start_line(launch: &CockpitLaunch, cockpit_token: &str) -> Option<Vec<u8>> {
894 let engine_exe = std::env::current_exe().ok()?;
895 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 let home_dir = dirs::home_dir()
904 .filter(|home| home.is_absolute())
905 .and_then(|home| home.to_str().map(str::to_owned));
906 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 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 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 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 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 assert!(!install_if_missing(true));
1085 }
1086}