Skip to main content

lean_ctx/
proxy_autostart.rs

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/// PID the proxy on this instance's configured port reports in `/health`,
14/// on every platform. Used by the lifecycle commands to stop the proxy this
15/// instance owns — never by process name (#2176). The caller still gates the
16/// PID by command line and process identity before signalling it.
17pub(crate) fn recorded_proxy_pid() -> Option<u32> {
18    health_proxy_pid(crate::core::proxy_endpoint::default_port())
19}
20
21/// PID a lean-ctx proxy on `port` reports in `/health`. Only an answer that
22/// identifies itself as the lean-ctx proxy counts (#2176 review #5); the PID
23/// is still only a candidate — `ipc::process` gates it by command line,
24/// executable and user (the caller's uid / token SID) before any signal.
25#[must_use]
26pub fn health_proxy_pid(port: u16) -> Option<u32> {
27    let response = ureq::get(&format!("http://127.0.0.1:{port}/health"))
28        .config()
29        .timeout_global(Some(std::time::Duration::from_millis(500)))
30        .build()
31        .call()
32        .ok()?;
33    let body = response.into_body().read_to_string().ok()?;
34    proxy_pid_from_health(&body)
35}
36
37fn proxy_pid_from_health(body: &str) -> Option<u32> {
38    let health: serde_json::Value = serde_json::from_str(body).ok()?;
39    if health.get("status").and_then(serde_json::Value::as_str) != Some("ok")
40        || !health_identifies_lean_ctx_proxy(body)
41    {
42        return None;
43    }
44    let pid = u32::try_from(health.get("pid")?.as_u64()?).ok()?;
45    (pid > 0).then_some(pid)
46}
47
48fn health_identifies_lean_ctx_proxy(body: &str) -> bool {
49    serde_json::from_str::<serde_json::Value>(body)
50        .ok()
51        .and_then(|health| health.get("service")?.as_str().map(str::to_owned))
52        .is_some_and(|service| service == "lean-ctx-proxy")
53}
54
55/// The lean-ctx proxy of this user listening on `port`, gated like every
56/// other lifecycle target: `/health` names it, and the PID passes the
57/// command-line, executable, user and identity checks.
58#[must_use]
59pub fn proxy_on_port(port: u16) -> Option<crate::ipc::process::ServiceProcess> {
60    use crate::ipc::process::{RecordedPid, ServiceKind, ServiceSweep, find_service_processes};
61    let pid = health_proxy_pid(port)?;
62    find_service_processes(
63        &[RecordedPid::bare(pid)],
64        ServiceSweep::Only(ServiceKind::Proxy),
65    )
66    .into_iter()
67    .next()
68}
69
70#[cfg(any(target_os = "macos", target_os = "linux"))]
71fn port_is_open(port: u16) -> bool {
72    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
73    TcpStream::connect_timeout(&addr, Duration::from_millis(150)).is_ok()
74}
75
76/// Stop a proxy already owning `port` before a managed service is started.
77///
78/// Returns `false` when the listener cannot be positively identified as a
79/// lean-ctx proxy or when it does not release the port. Managed startup must
80/// fail closed in either case; otherwise launchd/systemd would enter a restart
81/// loop on `EADDRINUSE`.
82#[cfg(any(target_os = "macos", target_os = "linux"))]
83fn release_proxy_port(port: u16, quiet: bool) -> bool {
84    if !port_is_open(port) {
85        return true;
86    }
87
88    // #2176 review: the `/health` PID is signalled only while it is this
89    // user's lean-ctx proxy (command line, executable, identity); never this
90    // process or its ancestors. Anything else fails closed.
91    let Some(proxy) = proxy_on_port(port) else {
92        if !quiet {
93            eprintln!(
94                "  Refusing managed proxy startup: port {port} is occupied by an unidentified service."
95            );
96        }
97        return false;
98    };
99    let pid = proxy.pid;
100
101    proxy.signal(crate::ipc::process::StopSignal::Terminate);
102    let graceful_deadline = Instant::now() + Duration::from_secs(2);
103    while Instant::now() < graceful_deadline {
104        if !proxy.is_running() && !port_is_open(port) {
105            if !quiet {
106                eprintln!(
107                    "  Handed port {port} from standalone proxy PID {pid} to managed service."
108                );
109            }
110            return true;
111        }
112        std::thread::sleep(Duration::from_millis(50));
113    }
114
115    if proxy.is_running() {
116        proxy.signal(crate::ipc::process::StopSignal::Kill);
117    }
118    let force_deadline = Instant::now() + Duration::from_secs(1);
119    while Instant::now() < force_deadline {
120        if !port_is_open(port) {
121            if !quiet {
122                eprintln!(
123                    "  Handed port {port} from standalone proxy PID {pid} to managed service."
124                );
125            }
126            return true;
127        }
128        std::thread::sleep(Duration::from_millis(50));
129    }
130
131    if !quiet {
132        eprintln!("  Refusing managed proxy startup: port {port} was not released by PID {pid}.");
133    }
134    false
135}
136
137pub fn install(port: u16, quiet: bool) -> bool {
138    let binary = find_binary();
139    install_with_binary(&binary, port, quiet)
140}
141
142pub fn install_with_binary(binary: &str, port: u16, quiet: bool) -> bool {
143    if binary.is_empty() {
144        if !quiet {
145            tracing::error!("Cannot find lean-ctx binary for autostart");
146        }
147        return false;
148    }
149
150    #[cfg(target_os = "macos")]
151    {
152        install_launchagent(binary, port, quiet)
153    }
154
155    #[cfg(target_os = "linux")]
156    {
157        install_systemd(binary, port, quiet)
158    }
159
160    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
161    {
162        let _ = (binary, quiet);
163        println!("  Autostart not supported on this platform");
164        println!("  Run manually: lean-ctx proxy start --port={port}");
165        false
166    }
167}
168
169pub fn stop() {
170    #[cfg(target_os = "macos")]
171    {
172        let plist_path = launchagent_path();
173        if plist_path.exists() {
174            crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
175        }
176    }
177
178    #[cfg(target_os = "linux")]
179    {
180        let _ = std::process::Command::new("systemctl")
181            .args(["--user", "stop", SYSTEMD_SERVICE])
182            .output();
183    }
184}
185
186pub fn start() -> bool {
187    start_on_port(crate::core::proxy_endpoint::default_port())
188}
189
190/// Start the installed manager after atomically taking ownership of `port`.
191/// This also handles the case where an IDE/dashboard spawned a standalone
192/// proxy while the manager was temporarily unloaded.
193pub fn start_on_port(port: u16) -> bool {
194    #[cfg(target_os = "macos")]
195    {
196        let plist_path = launchagent_path();
197        if plist_path.exists() {
198            crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
199            if !release_proxy_port(port, false) {
200                return false;
201            }
202            return crate::core::launchd::bootstrap(PLIST_LABEL, &plist_path);
203        }
204        false
205    }
206
207    #[cfg(target_os = "linux")]
208    {
209        let _ = std::process::Command::new("systemctl")
210            .args(["--user", "stop", SYSTEMD_SERVICE])
211            .output();
212        if release_proxy_port(port, false) {
213            std::process::Command::new("systemctl")
214                .args(["--user", "start", SYSTEMD_SERVICE])
215                .status()
216                .is_ok_and(|status| status.success())
217        } else {
218            false
219        }
220    }
221
222    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
223    {
224        let _ = port;
225        false
226    }
227}
228
229pub fn uninstall(_quiet: bool) {
230    #[cfg(target_os = "macos")]
231    uninstall_launchagent(_quiet);
232
233    #[cfg(target_os = "linux")]
234    uninstall_systemd(_quiet);
235}
236
237/// Whether this platform has a proxy-autostart backend (LaunchAgent on macOS,
238/// systemd user service on Linux). Windows and other targets have none, so a
239/// missing autostart there must not be treated as a failure by `doctor` (#416).
240pub fn is_supported() -> bool {
241    cfg!(any(target_os = "macos", target_os = "linux"))
242}
243
244/// Returns true if the proxy autostart is installed (plist/systemd service file exists).
245pub fn is_installed() -> bool {
246    #[cfg(target_os = "macos")]
247    {
248        launchagent_path().exists()
249    }
250    #[cfg(target_os = "linux")]
251    {
252        systemd_path().exists()
253    }
254    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
255    {
256        false
257    }
258}
259
260/// Returns true when the installed manager is actively responsible for the proxy.
261/// Callers must not spawn a second foreground proxy while this is true.
262pub fn is_loaded() -> bool {
263    #[cfg(target_os = "macos")]
264    {
265        is_installed() && crate::core::launchd::is_loaded(PLIST_LABEL)
266    }
267    #[cfg(target_os = "linux")]
268    {
269        is_installed()
270            && std::process::Command::new("systemctl")
271                .args(["--user", "is-active", "--quiet", SYSTEMD_SERVICE])
272                .status()
273                .is_ok_and(|status| status.success())
274    }
275    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
276    {
277        false
278    }
279}
280
281pub fn status() {
282    #[cfg(target_os = "macos")]
283    {
284        let plist_path = launchagent_path();
285        if plist_path.exists() {
286            println!("  LaunchAgent: installed at {}", plist_path.display());
287            if crate::core::launchd::is_loaded(PLIST_LABEL) {
288                println!("  Status: loaded");
289            } else {
290                println!("  Status: not loaded (run: lean-ctx proxy start)");
291            }
292        } else {
293            println!("  LaunchAgent: not installed");
294        }
295    }
296
297    #[cfg(target_os = "linux")]
298    {
299        let service_path = systemd_path();
300        if service_path.exists() {
301            println!("  systemd user service: installed");
302            let output = std::process::Command::new("systemctl")
303                .args(["--user", "is-active", SYSTEMD_SERVICE])
304                .output();
305            match output {
306                Ok(o) => {
307                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
308                    println!("  Status: {state}");
309                }
310                Err(_) => println!("  Status: unknown"),
311            }
312        } else {
313            println!("  systemd service: not installed");
314        }
315    }
316
317    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
318    {
319        println!("  Autostart not available on this platform");
320    }
321}
322
323#[cfg(target_os = "macos")]
324fn launchagent_path() -> PathBuf {
325    dirs::home_dir()
326        .unwrap_or_else(|| PathBuf::from("/tmp"))
327        .join("Library/LaunchAgents")
328        .join(format!("{PLIST_LABEL}.plist"))
329}
330
331#[cfg(target_os = "macos")]
332fn install_launchagent(binary: &str, port: u16, quiet: bool) -> bool {
333    let plist_dir = dirs::home_dir()
334        .unwrap_or_else(|| PathBuf::from("/tmp"))
335        .join("Library/LaunchAgents");
336    let _ = std::fs::create_dir_all(&plist_dir);
337
338    let plist_path = plist_dir.join(format!("{PLIST_LABEL}.plist"));
339    // Stop the registered job first, then take over any standalone proxy an
340    // IDE/dashboard may have spawned while launchd was unloaded. Starting the
341    // new job with an occupied port causes an unconditional KeepAlive loop.
342    crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
343    if !release_proxy_port(port, quiet) {
344        return false;
345    }
346    // GH #439: proxy logs are STATE — resolve through the typed dir so a
347    // post-split install writes to $XDG_STATE_HOME/lean-ctx/logs instead of a
348    // re-created ~/.lean-ctx. Legacy single-dir installs still resolve here.
349    let log_dir = crate::core::paths::state_dir()
350        .unwrap_or_else(|_| std::env::temp_dir().join("lean-ctx"))
351        .join("logs");
352    let _ = std::fs::create_dir_all(&log_dir);
353
354    // #356: wrap the launchd invocation in a deny-~/Documents seatbelt sandbox
355    // so the proxy (a TCC-standalone process) can never trip the privacy prompt.
356    let port_arg = format!("--port={port}");
357    let program_args = crate::core::tcc_guard_sandbox::program_args_xml(
358        &crate::core::tcc_guard_sandbox::wrap_launchd_args(binary, &["proxy", "start", &port_arg]),
359        "        ",
360    );
361
362    // #449: pin the directory layout. A launchd-spawned proxy inherits only
363    // launchd's minimal environment (no HOME, no XDG vars), so it resolves a
364    // *different* config/data dir than the CLI that installed it — it never sees
365    // the user's config.toml edits (live-upstream reload reads nothing) and
366    // derives a mismatched session token. Bake the exact dirs this CLI resolves
367    // into the plist so the managed proxy always agrees with the CLI.
368    let env_vars = crate::core::tcc_guard_sandbox::pinned_layout_env_xml();
369
370    let plist = format!(
371        r#"<?xml version="1.0" encoding="UTF-8"?>
372<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
373<plist version="1.0">
374<dict>
375    <key>Label</key>
376    <string>{PLIST_LABEL}</string>
377    <key>ProgramArguments</key>
378    <array>
379{program_args}
380    </array>
381{env_vars}    <key>RunAtLoad</key>
382    <true/>
383    <key>KeepAlive</key>
384    <true/>
385    <key>StandardOutPath</key>
386    <string>{stdout}</string>
387    <key>StandardErrorPath</key>
388    <string>{stderr}</string>
389</dict>
390</plist>"#,
391        stdout = log_dir.join("proxy.stdout.log").display(),
392        stderr = log_dir.join("proxy.stderr.log").display(),
393    );
394
395    let _ = std::fs::write(&plist_path, &plist);
396
397    let ok = crate::core::launchd::bootstrap(PLIST_LABEL, &plist_path);
398
399    if !quiet {
400        if ok {
401            println!("  Installed LaunchAgent: {}", plist_path.display());
402            println!("  Proxy will start on login and restart if stopped");
403        } else {
404            println!("  Created LaunchAgent at {}", plist_path.display());
405            println!("  Load reported a problem; check: launchctl print {PLIST_LABEL}");
406        }
407    }
408    ok
409}
410
411#[cfg(target_os = "macos")]
412fn uninstall_launchagent(quiet: bool) {
413    let plist_path = launchagent_path();
414    if !plist_path.exists() {
415        if !quiet {
416            println!("  LaunchAgent not installed, nothing to remove");
417        }
418        return;
419    }
420
421    crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
422
423    let _ = std::fs::remove_file(&plist_path);
424    if !quiet {
425        println!("  Removed LaunchAgent: {}", plist_path.display());
426    }
427}
428
429#[cfg(target_os = "linux")]
430fn systemd_path() -> PathBuf {
431    dirs::home_dir()
432        .unwrap_or_else(|| PathBuf::from("/tmp"))
433        .join(".config/systemd/user")
434        .join(format!("{SYSTEMD_SERVICE}.service"))
435}
436
437#[cfg(target_os = "linux")]
438fn install_systemd(binary: &str, port: u16, quiet: bool) -> bool {
439    let service_dir = dirs::home_dir()
440        .unwrap_or_else(|| PathBuf::from("/tmp"))
441        .join(".config/systemd/user");
442    let _ = std::fs::create_dir_all(&service_dir);
443
444    let service_path = service_dir.join(format!("{SYSTEMD_SERVICE}.service"));
445
446    let _ = std::process::Command::new("systemctl")
447        .args(["--user", "stop", SYSTEMD_SERVICE])
448        .output();
449    if !release_proxy_port(port, quiet) {
450        return false;
451    }
452
453    let unit = format!(
454        r"[Unit]
455Description=lean-ctx API Proxy
456After=network.target
457StartLimitIntervalSec=300
458StartLimitBurst=5
459
460[Service]
461Type=simple
462ExecStart={binary} proxy start --port={port}
463Restart=on-failure
464RestartSec=5
465StandardOutput=journal
466StandardError=journal
467Environment=RUST_LOG=info
468
469[Install]
470WantedBy=default.target
471"
472    );
473
474    let _ = std::fs::write(&service_path, &unit);
475
476    let _ = std::process::Command::new("systemctl")
477        .args(["--user", "daemon-reload"])
478        .output();
479
480    let result = std::process::Command::new("systemctl")
481        .args(["--user", "enable", "--now", SYSTEMD_SERVICE])
482        .output();
483
484    if !quiet {
485        match &result {
486            Ok(o) if o.status.success() => {
487                println!("  Installed systemd user service: {SYSTEMD_SERVICE}");
488                println!("  Proxy will start on login and restart if stopped");
489            }
490            Ok(o) => {
491                let err = String::from_utf8_lossy(&o.stderr);
492                println!("  Created service file but enable failed: {err}");
493            }
494            Err(e) => {
495                println!("  Created service file at {}", service_path.display());
496                println!("  Could not enable: {e}");
497            }
498        }
499    }
500    result.is_ok_and(|output| output.status.success())
501}
502
503#[cfg(target_os = "linux")]
504fn uninstall_systemd(quiet: bool) {
505    let service_path = systemd_path();
506    if !service_path.exists() {
507        if !quiet {
508            println!("  systemd service not installed, nothing to remove");
509        }
510        return;
511    }
512
513    let _ = std::process::Command::new("systemctl")
514        .args(["--user", "stop", SYSTEMD_SERVICE])
515        .output();
516    let _ = std::process::Command::new("systemctl")
517        .args(["--user", "disable", SYSTEMD_SERVICE])
518        .output();
519    let _ = std::fs::remove_file(&service_path);
520    let _ = std::process::Command::new("systemctl")
521        .args(["--user", "daemon-reload"])
522        .output();
523
524    if !quiet {
525        println!("  Removed systemd service: {SYSTEMD_SERVICE}");
526    }
527}
528
529pub fn find_binary() -> String {
530    crate::core::portable_binary::resolve_portable_binary()
531}
532
533#[cfg(test)]
534mod tests {
535    use super::{health_identifies_lean_ctx_proxy, proxy_pid_from_health};
536    #[cfg(any(target_os = "macos", target_os = "linux"))]
537    use super::{port_is_open, release_proxy_port};
538    #[cfg(any(target_os = "macos", target_os = "linux"))]
539    use std::io::{Read, Write};
540    #[cfg(any(target_os = "macos", target_os = "linux"))]
541    use std::net::TcpListener;
542
543    #[test]
544    fn health_pid_parser_accepts_only_well_formed_health() {
545        assert_eq!(
546            proxy_pid_from_health(r#"{"status":"ok","service":"lean-ctx-proxy","pid":4242}"#),
547            Some(4242)
548        );
549        // #2176 review #5: an answer that does not identify the lean-ctx
550        // proxy names no candidate.
551        assert_eq!(proxy_pid_from_health(r#"{"status":"ok","pid":4242}"#), None);
552        assert_eq!(
553            proxy_pid_from_health(r#"{"status":"ok","service":"other","pid":4242}"#),
554            None
555        );
556        assert_eq!(
557            proxy_pid_from_health(r#"{"status":"busy","service":"lean-ctx-proxy","pid":4242}"#),
558            None
559        );
560        assert_eq!(
561            proxy_pid_from_health(r#"{"status":"ok","service":"lean-ctx-proxy","pid":0}"#),
562            None
563        );
564        assert_eq!(proxy_pid_from_health(r#"{"status":"ok"}"#), None);
565        assert_eq!(proxy_pid_from_health("not json"), None);
566        assert!(health_identifies_lean_ctx_proxy(
567            r#"{"status":"ok","service":"lean-ctx-proxy","pid":4242}"#
568        ));
569        assert!(!health_identifies_lean_ctx_proxy(
570            r#"{"status":"ok","pid":4242}"#
571        ));
572    }
573
574    #[test]
575    #[cfg(any(target_os = "macos", target_os = "linux"))]
576    fn managed_handoff_fails_closed_for_unidentified_listener() {
577        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
578        let port = listener.local_addr().unwrap().port();
579        let server = std::thread::spawn(move || {
580            for _ in 0..2 {
581                let (mut stream, _) = listener.accept().unwrap();
582                stream
583                    .set_read_timeout(Some(std::time::Duration::from_secs(1)))
584                    .unwrap();
585                let mut request = [0_u8; 512];
586                if stream.read(&mut request).unwrap_or(0) > 0 {
587                    let body = r#"{"status":"ok"}"#;
588                    write!(
589                        stream,
590                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
591                        body.len()
592                    )
593                    .unwrap();
594                }
595            }
596        });
597
598        assert!(!release_proxy_port(port, true));
599        server.join().unwrap();
600    }
601
602    #[cfg(unix)]
603    struct ChildGuard(std::process::Child);
604
605    #[cfg(unix)]
606    impl Drop for ChildGuard {
607        fn drop(&mut self) {
608            let _ = self.0.kill();
609            let _ = self.0.wait();
610        }
611    }
612
613    /// #2176 review: a listener that claims to be the lean-ctx proxy in
614    /// `/health` but is not a lean-ctx `proxy start` process (here the test
615    /// binary) is never signalled; the hand-off fails closed.
616    #[test]
617    #[cfg(unix)]
618    fn managed_handoff_never_signals_an_unverified_health_pid() {
619        let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
620        let port = reservation.local_addr().unwrap().port();
621        drop(reservation);
622
623        let child = std::process::Command::new(std::env::current_exe().unwrap())
624            .args([
625                "--ignored",
626                "--exact",
627                "proxy_autostart::tests::managed_handoff_test_child",
628                "--nocapture",
629            ])
630            .env("LEAN_CTX_HANDOFF_TEST_PORT", port.to_string())
631            .spawn()
632            .unwrap();
633        let mut child = ChildGuard(child);
634
635        let ready_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
636        while !port_is_open(port) && std::time::Instant::now() < ready_deadline {
637            std::thread::sleep(std::time::Duration::from_millis(25));
638        }
639        assert!(port_is_open(port), "test proxy did not bind port {port}");
640        assert!(!release_proxy_port(port, true));
641        assert!(
642            child.0.try_wait().unwrap().is_none(),
643            "an unverified /health PID must never be signalled"
644        );
645        assert!(port_is_open(port));
646    }
647
648    #[test]
649    #[ignore = "helper process for managed_handoff_never_signals_an_unverified_health_pid"]
650    #[cfg(unix)]
651    fn managed_handoff_test_child() {
652        let port: u16 = std::env::var("LEAN_CTX_HANDOFF_TEST_PORT")
653            .unwrap()
654            .parse()
655            .unwrap();
656        let listener = TcpListener::bind(("127.0.0.1", port)).unwrap();
657        loop {
658            let (mut stream, _) = listener.accept().unwrap();
659            stream
660                .set_read_timeout(Some(std::time::Duration::from_secs(1)))
661                .unwrap();
662            let mut request = [0_u8; 512];
663            if stream.read(&mut request).unwrap_or(0) > 0 {
664                let body = format!(
665                    r#"{{"status":"ok","service":"lean-ctx-proxy","pid":{}}}"#,
666                    std::process::id()
667                );
668                write!(
669                    stream,
670                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
671                    body.len()
672                )
673                .unwrap();
674            }
675        }
676    }
677}