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 {
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
131const 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
140fn 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
207fn 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
227pub(crate) fn install_from(path: &Path, downgrade: bool) -> Result<serde_json::Value> {
229 import(&read_bundle(path)?, downgrade)
230}
231
232pub 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
278fn 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
288fn 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 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
344fn 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
351fn 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
369fn 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
393fn 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
415fn 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
436pub 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
451pub 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
468fn installs_opted_out() -> bool {
470 std::env::var_os("LEAN_CTX_NO_COCKPIT").is_some_and(|value| !value.is_empty() && value != "0")
471}
472
473pub 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
488const 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
497fn 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
504pub(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
544pub 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
554pub(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
583pub(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 "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
617pub(super) struct Process {
619 child: Child,
620 stdin: Option<ChildStdin>,
621 port: u16,
622}
623
624impl Process {
625 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
656pub(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 .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 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
712fn 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 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: 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
770pub 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 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 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
832pub 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
875fn start_line(launch: &CockpitLaunch, cockpit_token: &str) -> Option<Vec<u8>> {
877 let engine_exe = std::env::current_exe().ok()?;
878 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 let home_dir = dirs::home_dir()
887 .filter(|home| home.is_absolute())
888 .and_then(|home| home.to_str().map(str::to_owned));
889 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 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 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 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 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 assert!(!install_if_missing(true));
1068 }
1069}