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#[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/// Stop a proxy already owning `port` before a managed service is started.
54///
55/// Returns `false` when the listener cannot be positively identified as a
56/// lean-ctx proxy or when it does not release the port. Managed startup must
57/// fail closed in either case; otherwise launchd/systemd would enter a restart
58/// loop on `EADDRINUSE`.
59#[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
165/// Start the installed manager after atomically taking ownership of `port`.
166/// This also handles the case where an IDE/dashboard spawned a standalone
167/// proxy while the manager was temporarily unloaded.
168pub 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
212/// Whether this platform has a proxy-autostart backend (LaunchAgent on macOS,
213/// systemd user service on Linux). Windows and other targets have none, so a
214/// missing autostart there must not be treated as a failure by `doctor` (#416).
215pub fn is_supported() -> bool {
216    cfg!(any(target_os = "macos", target_os = "linux"))
217}
218
219/// Returns true if the proxy autostart is installed (plist/systemd service file exists).
220pub 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
235/// Returns true when the installed manager is actively responsible for the proxy.
236/// Callers must not spawn a second foreground proxy while this is true.
237pub 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    // Stop the registered job first, then take over any standalone proxy an
315    // IDE/dashboard may have spawned while launchd was unloaded. Starting the
316    // new job with an occupied port causes an unconditional KeepAlive loop.
317    crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
318    if !release_proxy_port(port, quiet) {
319        return false;
320    }
321    // GH #439: proxy logs are STATE — resolve through the typed dir so a
322    // post-split install writes to $XDG_STATE_HOME/lean-ctx/logs instead of a
323    // re-created ~/.lean-ctx. Legacy single-dir installs still resolve here.
324    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    // #356: wrap the launchd invocation in a deny-~/Documents seatbelt sandbox
330    // so the proxy (a TCC-standalone process) can never trip the privacy prompt.
331    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    // #449: pin the directory layout. A launchd-spawned proxy inherits only
338    // launchd's minimal environment (no HOME, no XDG vars), so it resolves a
339    // *different* config/data dir than the CLI that installed it — it never sees
340    // the user's config.toml edits (live-upstream reload reads nothing) and
341    // derives a mismatched session token. Bake the exact dirs this CLI resolves
342    // into the plist so the managed proxy always agrees with the CLI.
343    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}