1#[cfg(any(target_os = "macos", target_os = "linux"))]
2use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
3#[cfg(any(target_os = "macos", target_os = "linux"))]
4use std::path::PathBuf;
5#[cfg(any(target_os = "macos", target_os = "linux"))]
6use std::time::{Duration, Instant};
7
8#[cfg(target_os = "macos")]
9const PLIST_LABEL: &str = "com.leanctx.proxy";
10#[cfg(target_os = "linux")]
11const SYSTEMD_SERVICE: &str = "lean-ctx-proxy";
12
13#[cfg(any(target_os = "macos", target_os = "linux", test))]
14fn proxy_pid_from_health(body: &str) -> Option<u32> {
15 let health: serde_json::Value = serde_json::from_str(body).ok()?;
16 if health.get("status").and_then(serde_json::Value::as_str) != Some("ok") {
17 return None;
18 }
19 let pid = u32::try_from(health.get("pid")?.as_u64()?).ok()?;
20 (pid > 0).then_some(pid)
21}
22
23#[cfg(any(target_os = "macos", target_os = "linux", test))]
24fn health_identifies_lean_ctx_proxy(body: &str) -> bool {
25 serde_json::from_str::<serde_json::Value>(body)
26 .ok()
27 .and_then(|health| health.get("service")?.as_str().map(str::to_owned))
28 .is_some_and(|service| service == "lean-ctx-proxy")
29}
30
31#[cfg(any(target_os = "macos", target_os = "linux"))]
32fn port_is_open(port: u16) -> bool {
33 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
34 TcpStream::connect_timeout(&addr, Duration::from_millis(150)).is_ok()
35}
36
37#[cfg(any(target_os = "macos", target_os = "linux"))]
38fn proxy_pid_on_port(port: u16) -> Option<u32> {
39 let health_url = format!("http://127.0.0.1:{port}/health");
40 let response = ureq::get(&health_url)
41 .config()
42 .timeout_global(Some(Duration::from_millis(500)))
43 .build()
44 .call()
45 .ok()?;
46 let body = response.into_body().read_to_string().ok()?;
47 let pid = proxy_pid_from_health(&body)?;
48 (health_identifies_lean_ctx_proxy(&body)
49 || crate::ipc::process::find_pids_by_name("lean-ctx").contains(&pid))
50 .then_some(pid)
51}
52
53#[cfg(any(target_os = "macos", target_os = "linux"))]
60fn release_proxy_port(port: u16, quiet: bool) -> bool {
61 if !port_is_open(port) {
62 return true;
63 }
64
65 let Some(pid) = proxy_pid_on_port(port) else {
66 if !quiet {
67 eprintln!(
68 " Refusing managed proxy startup: port {port} is occupied by an unidentified service."
69 );
70 }
71 return false;
72 };
73 if pid == std::process::id() {
74 if !quiet {
75 eprintln!(" Refusing to stop the current process while handing off port {port}.");
76 }
77 return false;
78 }
79
80 let _ = crate::ipc::process::terminate_gracefully(pid);
81 let graceful_deadline = Instant::now() + Duration::from_secs(2);
82 while Instant::now() < graceful_deadline {
83 if !crate::ipc::process::is_alive(pid) && !port_is_open(port) {
84 if !quiet {
85 eprintln!(
86 " Handed port {port} from standalone proxy PID {pid} to managed service."
87 );
88 }
89 return true;
90 }
91 std::thread::sleep(Duration::from_millis(50));
92 }
93
94 if crate::ipc::process::is_alive(pid) {
95 let _ = crate::ipc::process::force_kill(pid);
96 }
97 let force_deadline = Instant::now() + Duration::from_secs(1);
98 while Instant::now() < force_deadline {
99 if !port_is_open(port) {
100 if !quiet {
101 eprintln!(
102 " Handed port {port} from standalone proxy PID {pid} to managed service."
103 );
104 }
105 return true;
106 }
107 std::thread::sleep(Duration::from_millis(50));
108 }
109
110 if !quiet {
111 eprintln!(" Refusing managed proxy startup: port {port} was not released by PID {pid}.");
112 }
113 false
114}
115
116pub fn install(port: u16, quiet: bool) -> bool {
117 let binary = find_binary();
118 if binary.is_empty() {
119 if !quiet {
120 tracing::error!("Cannot find lean-ctx binary for autostart");
121 }
122 return false;
123 }
124
125 #[cfg(target_os = "macos")]
126 {
127 install_launchagent(&binary, port, quiet)
128 }
129
130 #[cfg(target_os = "linux")]
131 {
132 install_systemd(&binary, port, quiet)
133 }
134
135 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
136 {
137 let _ = (&binary, quiet);
138 println!(" Autostart not supported on this platform");
139 println!(" Run manually: lean-ctx proxy start --port={port}");
140 false
141 }
142}
143
144pub fn stop() {
145 #[cfg(target_os = "macos")]
146 {
147 let plist_path = launchagent_path();
148 if plist_path.exists() {
149 crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
150 }
151 }
152
153 #[cfg(target_os = "linux")]
154 {
155 let _ = std::process::Command::new("systemctl")
156 .args(["--user", "stop", SYSTEMD_SERVICE])
157 .output();
158 }
159}
160
161pub fn start() -> bool {
162 start_on_port(crate::proxy_setup::default_port())
163}
164
165pub fn start_on_port(port: u16) -> bool {
169 #[cfg(target_os = "macos")]
170 {
171 let plist_path = launchagent_path();
172 if plist_path.exists() {
173 crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
174 if !release_proxy_port(port, false) {
175 return false;
176 }
177 return crate::core::launchd::bootstrap(PLIST_LABEL, &plist_path);
178 }
179 false
180 }
181
182 #[cfg(target_os = "linux")]
183 {
184 let _ = std::process::Command::new("systemctl")
185 .args(["--user", "stop", SYSTEMD_SERVICE])
186 .output();
187 if release_proxy_port(port, false) {
188 std::process::Command::new("systemctl")
189 .args(["--user", "start", SYSTEMD_SERVICE])
190 .status()
191 .is_ok_and(|status| status.success())
192 } else {
193 false
194 }
195 }
196
197 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
198 {
199 let _ = port;
200 false
201 }
202}
203
204pub fn uninstall(_quiet: bool) {
205 #[cfg(target_os = "macos")]
206 uninstall_launchagent(_quiet);
207
208 #[cfg(target_os = "linux")]
209 uninstall_systemd(_quiet);
210}
211
212pub fn is_supported() -> bool {
216 cfg!(any(target_os = "macos", target_os = "linux"))
217}
218
219pub fn is_installed() -> bool {
221 #[cfg(target_os = "macos")]
222 {
223 launchagent_path().exists()
224 }
225 #[cfg(target_os = "linux")]
226 {
227 systemd_path().exists()
228 }
229 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
230 {
231 false
232 }
233}
234
235pub fn is_loaded() -> bool {
238 #[cfg(target_os = "macos")]
239 {
240 is_installed() && crate::core::launchd::is_loaded(PLIST_LABEL)
241 }
242 #[cfg(target_os = "linux")]
243 {
244 is_installed()
245 && std::process::Command::new("systemctl")
246 .args(["--user", "is-active", "--quiet", SYSTEMD_SERVICE])
247 .status()
248 .is_ok_and(|status| status.success())
249 }
250 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
251 {
252 false
253 }
254}
255
256pub fn status() {
257 #[cfg(target_os = "macos")]
258 {
259 let plist_path = launchagent_path();
260 if plist_path.exists() {
261 println!(" LaunchAgent: installed at {}", plist_path.display());
262 if crate::core::launchd::is_loaded(PLIST_LABEL) {
263 println!(" Status: loaded");
264 } else {
265 println!(" Status: not loaded (run: lean-ctx proxy start)");
266 }
267 } else {
268 println!(" LaunchAgent: not installed");
269 }
270 }
271
272 #[cfg(target_os = "linux")]
273 {
274 let service_path = systemd_path();
275 if service_path.exists() {
276 println!(" systemd user service: installed");
277 let output = std::process::Command::new("systemctl")
278 .args(["--user", "is-active", SYSTEMD_SERVICE])
279 .output();
280 match output {
281 Ok(o) => {
282 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
283 println!(" Status: {state}");
284 }
285 Err(_) => println!(" Status: unknown"),
286 }
287 } else {
288 println!(" systemd service: not installed");
289 }
290 }
291
292 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
293 {
294 println!(" Autostart not available on this platform");
295 }
296}
297
298#[cfg(target_os = "macos")]
299fn launchagent_path() -> PathBuf {
300 dirs::home_dir()
301 .unwrap_or_else(|| PathBuf::from("/tmp"))
302 .join("Library/LaunchAgents")
303 .join(format!("{PLIST_LABEL}.plist"))
304}
305
306#[cfg(target_os = "macos")]
307fn install_launchagent(binary: &str, port: u16, quiet: bool) -> bool {
308 let plist_dir = dirs::home_dir()
309 .unwrap_or_else(|| PathBuf::from("/tmp"))
310 .join("Library/LaunchAgents");
311 let _ = std::fs::create_dir_all(&plist_dir);
312
313 let plist_path = plist_dir.join(format!("{PLIST_LABEL}.plist"));
314 crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
318 if !release_proxy_port(port, quiet) {
319 return false;
320 }
321 let log_dir = crate::core::paths::state_dir()
325 .unwrap_or_else(|_| std::env::temp_dir().join("lean-ctx"))
326 .join("logs");
327 let _ = std::fs::create_dir_all(&log_dir);
328
329 let port_arg = format!("--port={port}");
332 let program_args = crate::core::tcc_guard_sandbox::program_args_xml(
333 &crate::core::tcc_guard_sandbox::wrap_launchd_args(binary, &["proxy", "start", &port_arg]),
334 " ",
335 );
336
337 let env_vars = crate::core::tcc_guard_sandbox::pinned_layout_env_xml();
344
345 let plist = format!(
346 r#"<?xml version="1.0" encoding="UTF-8"?>
347<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
348<plist version="1.0">
349<dict>
350 <key>Label</key>
351 <string>{PLIST_LABEL}</string>
352 <key>ProgramArguments</key>
353 <array>
354{program_args}
355 </array>
356{env_vars} <key>RunAtLoad</key>
357 <true/>
358 <key>KeepAlive</key>
359 <true/>
360 <key>StandardOutPath</key>
361 <string>{stdout}</string>
362 <key>StandardErrorPath</key>
363 <string>{stderr}</string>
364</dict>
365</plist>"#,
366 stdout = log_dir.join("proxy.stdout.log").display(),
367 stderr = log_dir.join("proxy.stderr.log").display(),
368 );
369
370 let _ = std::fs::write(&plist_path, &plist);
371
372 let ok = crate::core::launchd::bootstrap(PLIST_LABEL, &plist_path);
373
374 if !quiet {
375 if ok {
376 println!(" Installed LaunchAgent: {}", plist_path.display());
377 println!(" Proxy will start on login and restart if stopped");
378 } else {
379 println!(" Created LaunchAgent at {}", plist_path.display());
380 println!(" Load reported a problem; check: launchctl print {PLIST_LABEL}");
381 }
382 }
383 ok
384}
385
386#[cfg(target_os = "macos")]
387fn uninstall_launchagent(quiet: bool) {
388 let plist_path = launchagent_path();
389 if !plist_path.exists() {
390 if !quiet {
391 println!(" LaunchAgent not installed, nothing to remove");
392 }
393 return;
394 }
395
396 crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
397
398 let _ = std::fs::remove_file(&plist_path);
399 if !quiet {
400 println!(" Removed LaunchAgent: {}", plist_path.display());
401 }
402}
403
404#[cfg(target_os = "linux")]
405fn systemd_path() -> PathBuf {
406 dirs::home_dir()
407 .unwrap_or_else(|| PathBuf::from("/tmp"))
408 .join(".config/systemd/user")
409 .join(format!("{SYSTEMD_SERVICE}.service"))
410}
411
412#[cfg(target_os = "linux")]
413fn install_systemd(binary: &str, port: u16, quiet: bool) -> bool {
414 let service_dir = dirs::home_dir()
415 .unwrap_or_else(|| PathBuf::from("/tmp"))
416 .join(".config/systemd/user");
417 let _ = std::fs::create_dir_all(&service_dir);
418
419 let service_path = service_dir.join(format!("{SYSTEMD_SERVICE}.service"));
420
421 let _ = std::process::Command::new("systemctl")
422 .args(["--user", "stop", SYSTEMD_SERVICE])
423 .output();
424 if !release_proxy_port(port, quiet) {
425 return false;
426 }
427
428 let unit = format!(
429 r"[Unit]
430Description=lean-ctx API Proxy
431After=network.target
432StartLimitIntervalSec=300
433StartLimitBurst=5
434
435[Service]
436Type=simple
437ExecStart={binary} proxy start --port={port}
438Restart=on-failure
439RestartSec=5
440StandardOutput=journal
441StandardError=journal
442Environment=RUST_LOG=info
443
444[Install]
445WantedBy=default.target
446"
447 );
448
449 let _ = std::fs::write(&service_path, &unit);
450
451 let _ = std::process::Command::new("systemctl")
452 .args(["--user", "daemon-reload"])
453 .output();
454
455 let result = std::process::Command::new("systemctl")
456 .args(["--user", "enable", "--now", SYSTEMD_SERVICE])
457 .output();
458
459 if !quiet {
460 match &result {
461 Ok(o) if o.status.success() => {
462 println!(" Installed systemd user service: {SYSTEMD_SERVICE}");
463 println!(" Proxy will start on login and restart if stopped");
464 }
465 Ok(o) => {
466 let err = String::from_utf8_lossy(&o.stderr);
467 println!(" Created service file but enable failed: {err}");
468 }
469 Err(e) => {
470 println!(" Created service file at {}", service_path.display());
471 println!(" Could not enable: {e}");
472 }
473 }
474 }
475 result.is_ok_and(|output| output.status.success())
476}
477
478#[cfg(target_os = "linux")]
479fn uninstall_systemd(quiet: bool) {
480 let service_path = systemd_path();
481 if !service_path.exists() {
482 if !quiet {
483 println!(" systemd service not installed, nothing to remove");
484 }
485 return;
486 }
487
488 let _ = std::process::Command::new("systemctl")
489 .args(["--user", "stop", SYSTEMD_SERVICE])
490 .output();
491 let _ = std::process::Command::new("systemctl")
492 .args(["--user", "disable", SYSTEMD_SERVICE])
493 .output();
494 let _ = std::fs::remove_file(&service_path);
495 let _ = std::process::Command::new("systemctl")
496 .args(["--user", "daemon-reload"])
497 .output();
498
499 if !quiet {
500 println!(" Removed systemd service: {SYSTEMD_SERVICE}");
501 }
502}
503
504pub fn find_binary() -> String {
505 crate::core::portable_binary::resolve_portable_binary()
506}
507
508#[cfg(test)]
509mod tests {
510 use super::{health_identifies_lean_ctx_proxy, proxy_pid_from_health};
511 #[cfg(any(target_os = "macos", target_os = "linux"))]
512 use super::{port_is_open, release_proxy_port};
513 #[cfg(any(target_os = "macos", target_os = "linux"))]
514 use std::io::{Read, Write};
515 #[cfg(any(target_os = "macos", target_os = "linux"))]
516 use std::net::TcpListener;
517
518 #[test]
519 fn health_pid_parser_accepts_only_well_formed_health() {
520 assert_eq!(
521 proxy_pid_from_health(r#"{"status":"ok","pid":4242}"#),
522 Some(4242)
523 );
524 assert_eq!(
525 proxy_pid_from_health(r#"{"status":"busy","pid":4242}"#),
526 None
527 );
528 assert_eq!(proxy_pid_from_health(r#"{"status":"ok","pid":0}"#), None);
529 assert_eq!(proxy_pid_from_health(r#"{"status":"ok"}"#), None);
530 assert_eq!(proxy_pid_from_health("not json"), None);
531 assert!(health_identifies_lean_ctx_proxy(
532 r#"{"status":"ok","service":"lean-ctx-proxy","pid":4242}"#
533 ));
534 assert!(!health_identifies_lean_ctx_proxy(
535 r#"{"status":"ok","pid":4242}"#
536 ));
537 }
538
539 #[test]
540 #[cfg(any(target_os = "macos", target_os = "linux"))]
541 fn managed_handoff_fails_closed_for_unidentified_listener() {
542 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
543 let port = listener.local_addr().unwrap().port();
544 let server = std::thread::spawn(move || {
545 for _ in 0..2 {
546 let (mut stream, _) = listener.accept().unwrap();
547 stream
548 .set_read_timeout(Some(std::time::Duration::from_secs(1)))
549 .unwrap();
550 let mut request = [0_u8; 512];
551 if stream.read(&mut request).unwrap_or(0) > 0 {
552 let body = r#"{"status":"ok"}"#;
553 write!(
554 stream,
555 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
556 body.len()
557 )
558 .unwrap();
559 }
560 }
561 });
562
563 assert!(!release_proxy_port(port, true));
564 server.join().unwrap();
565 }
566
567 #[cfg(unix)]
568 struct ChildGuard(std::process::Child);
569
570 #[cfg(unix)]
571 impl Drop for ChildGuard {
572 fn drop(&mut self) {
573 let _ = self.0.kill();
574 let _ = self.0.wait();
575 }
576 }
577
578 #[test]
579 #[cfg(unix)]
580 fn managed_handoff_stops_identified_proxy_and_releases_port() {
581 let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
582 let port = reservation.local_addr().unwrap().port();
583 drop(reservation);
584
585 let child = std::process::Command::new(std::env::current_exe().unwrap())
586 .args([
587 "--ignored",
588 "--exact",
589 "proxy_autostart::tests::managed_handoff_test_child",
590 "--nocapture",
591 ])
592 .env("LEAN_CTX_HANDOFF_TEST_PORT", port.to_string())
593 .spawn()
594 .unwrap();
595 let mut child = ChildGuard(child);
596
597 let ready_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
598 while !port_is_open(port) && std::time::Instant::now() < ready_deadline {
599 std::thread::sleep(std::time::Duration::from_millis(25));
600 }
601 assert!(port_is_open(port), "test proxy did not bind port {port}");
602 assert!(release_proxy_port(port, true));
603 let status = child.0.wait().unwrap();
604 assert!(
605 !status.success(),
606 "handoff must terminate the standalone proxy"
607 );
608 assert!(!port_is_open(port), "handoff must release port {port}");
609 }
610
611 #[test]
612 #[ignore = "helper process for managed_handoff_stops_identified_proxy_and_releases_port"]
613 #[cfg(unix)]
614 fn managed_handoff_test_child() {
615 let port: u16 = std::env::var("LEAN_CTX_HANDOFF_TEST_PORT")
616 .unwrap()
617 .parse()
618 .unwrap();
619 let listener = TcpListener::bind(("127.0.0.1", port)).unwrap();
620 loop {
621 let (mut stream, _) = listener.accept().unwrap();
622 stream
623 .set_read_timeout(Some(std::time::Duration::from_secs(1)))
624 .unwrap();
625 let mut request = [0_u8; 512];
626 if stream.read(&mut request).unwrap_or(0) > 0 {
627 let body = format!(
628 r#"{{"status":"ok","service":"lean-ctx-proxy","pid":{}}}"#,
629 std::process::id()
630 );
631 write!(
632 stream,
633 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
634 body.len()
635 )
636 .unwrap();
637 }
638 }
639 }
640}